diff --git a/.gitignore b/.gitignore index 2836d9e..d89a629 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +*.bak secrets.cfg # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..a1ff1fb --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +_Note: 'Unreleased' section below is used for untagged changes that will be issued with the next version bump_ + +### [Unreleased] - 2022-00-00 +#### Added +#### Changed +#### Deprecated +#### Removed +#### Fixed +#### Security +__BEGIN-CHANGELOG__ + +### [1.1.2] - 2025-06-02 +#### Changed + - Loosen loguru reqs + +### [1.1.1] - 2025-06-02 +#### Added + - Loosened pinned versions (yikes -- this is a library) + - Tested in py312, likely need to more thoroughly test later + +### [1.0.1] - 2022-04-24 +#### Added + - More type hints (albeit they'll need to be made more precise later) + - `PyYAML` was left out of requirements upon scanning the older `setup.py` file, so that was added & pinned +#### Changed + - `print` statements are now log outputs + - Some dictionaries were simplified and optimized +#### Fixed + - Minor variable misspellings + - `test_camera.py` now doesn't test a real camera connection + +### [1.0.0] - 2022-04-23 +#### Added + - `CHANGELOG.md` + - Python 3.10 support + - `tox` support + - PPM scripts for easier package support routines + +__END-CHANGELOG__ \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6a40f58 --- /dev/null +++ b/Makefile @@ -0,0 +1,34 @@ + +SHELL := /bin/bash + +bump-patch: + sh admin/admin-commands.sh --cmd bump --level patch +bump-minor: + sh admin/admin-commands.sh --cmd bump --level minor +bump-major: + sh admin/admin-commands.sh --cmd bump --level major +pull: + sh admin/admin-commands.sh --cmd pull +push: + sh admin/admin-commands.sh --cmd push +install: + # First-time install - use when lock file is stable + poetry install -v +update: + # Update lock file based on changed reqs + poetry update -v +lock: + # Refresh the poetry lock file + poetry lock +exp-reqs: + # Exports the current poetry env to requirements.frozen + poetry export -f requirements.txt > requirements.frozen +check: + pre-commit run --all-files +install-dev: + poetry install --all-groups + pre-commit install +test: + tox +rebuild-test: + tox --recreate -e py312 diff --git a/admin/.admin-config.yaml b/admin/.admin-config.yaml new file mode 100644 index 0000000..ece800e --- /dev/null +++ b/admin/.admin-config.yaml @@ -0,0 +1,6 @@ +PROJECT: reolink-python-api +PY_LIB_NAME: reolink_api +PROJECT_DIR: ${HOME}/extras/${PROJECT} +VENV_NAME: reolink-312 +VENV_PATH: ${HOME}/venvs/${VENV_NAME}/bin/python3 +MAIN_BRANCH: master \ No newline at end of file diff --git a/admin/admin-commands.sh b/admin/admin-commands.sh new file mode 100644 index 0000000..018c1c8 --- /dev/null +++ b/admin/admin-commands.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +#/ admin-commands.sh - v0.1.0 +#/ +#/ Commands +#/ ----------------- +#/ bump {major,minor,patch} +#/ version +#/ +#/ +#/ +#/ +#/ +#/ ------------------------------------- + +# VARIABLES +# -------------------------------------- +ADMIN_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) +FUNCTIONS_SH_PATH="${ADMIN_DIR}/admin-functions.sh" +ADMIN_CONFIG_YAML="${ADMIN_DIR}/.admin-config.yaml" +# -------------------------------------- + +if [[ -f "${FUNCTIONS_SH_PATH}" ]] +then + echo "Found functions at ${FUNCTIONS_SH_PATH}" +else + echo "Failed to admin-functions.sh file. (Looked at ${FUNCTIONS_SH_PATH}) Aborting..." + exit 1 +fi + +# Source functions +source ${FUNCTIONS_SH_PATH} +# Read in the yaml file, create variables for the items therein +create_variables ${ADMIN_CONFIG_YAML} "" + +announce_section "Scanning for needed files..." +if [[ ! -d ${PROJECT_DIR} ]] +then + make_log "error The project directory at path '${PROJECT_DIR}' was not found." + exit 1 +fi + +PYPROJECT_TOML_FPATH="${PROJECT_DIR}/pyproject.toml" +PY_INIT_FPATH="${PROJECT_DIR}/${PY_LIB_NAME}/__init__.py" +CHANGELOG_PATH="${PROJECT_DIR}/CHANGELOG.md" + +do_bump () { + # Get current version and calculate the new version based on level input + make_log "debug Determining version bump..." + bump_version ${PYPROJECT_TOML_FPATH} ${LEVEL} + + # Build changelog string + CHGLOG_STR="\ \n### [${NEW_VERSION}] - $(date +"%Y-%m-%d")\n#### Added\n#### Changed\n#### Deprecated\n#### Removed\n#### Fixed\n#### Security" + + CONFIRM_TXT=$(echo -e "Creating a ${GRN}RELEASE${RST}. Updating version '${BLU}${CUR_VERSION}${RST}' to '${RED}${NEW_VERSION}${RST}' in:\n\t - ${PYPROJECT_TOML_FPATH} \n\t - ${PY_INIT_FPATH} \n\t - ${CHANGELOG_PATH}\n ") + + # Confirm + read -p $"Confirm (y/n): ${CONFIRM_TXT}" -n 1 -r + echo # (optional) move to a new line + + if [[ ${REPLY} =~ ^[Yy]$ ]] + then + # 1. Apply new version & update date to file + make_log "debug Applying new version to files..." + # Logic: "Find line beginning with __version__, replace with such at file + if [[ -f "${PY_INIT_FPATH}" ]] + then + make_log "debug Applying to ${PY_INIT_FPATH} ..." + sed -i "s/^__version__.*/__version__ = '${NEW_VERSION}'/" ${PY_INIT_FPATH} + sed -i "s/^__update_date__.*/__update_date__ = '$(date +"%Y-%m-%d_%H:%M:%S")'/" ${PY_INIT_FPATH} + else + make_log "warn Skipping ${PY_INIT_FPATH} - No file found." + fi + # 1.1. Add version to pyproject.toml + sed -i "s/^version =.*/version = '${NEW_VERSION}'/" ${PYPROJECT_TOML_FPATH} + # 2. Insert new section and link in CHANGELOG.md + if [[ -f "${CHANGELOG_PATH}" ]] + then + make_log "debug Inserting new area in CHANGELOG for version..." + sed -i.bak "/__BEGIN-CHANGELOG__/a ${CHGLOG_STR}" ${CHANGELOG_PATH} + else + make_log "warn Skipping ${CHANGELOG_PATH} - No file found." + fi + make_log "info New version is ${RED}${NEW_VERSION}${RESET}" + make_log "info To finish, fill in CHANGELOG details and then ${RED}make push${RESET}" + else + make_log "info Cancelled procedure" + fi + +} + +do_pull () { + make_log "debug Confirming branch..." + confirm_branch ${MAIN_BRANCH} + # Update procedure + make_log "debug Pulling updates from remote ${RED}${MAIN_BRANCH}${RESET}..." + (git -C $PROJECT_DIR pull origin ${MAIN_BRANCH}) +} + +do_tag_and_push () { + make_log "debug Confirming branch..." + confirm_branch ${MAIN_BRANCH} + # Push to remote, tag + make_log "debug Getting version..." + get_version ${PYPROJECT_TOML_FPATH} + CUR_VERSION="v${CUR_VERSION}" + # First get the staged changes + STAGED_ITEMS_TXT="$(git -C ${PROJECT_DIR} diff --shortstat)" + CONFIRM_TXT=$(echo -e "Tagging the following changes with version ${RED}${CUR_VERSION}${RST} to ${RED}${MAIN_BRANCH}${RST}: \n\t${PRP}${STAGED_ITEMS_TXT}${RST} \n ") + # Confirm + read -p $"Confirm (y/n): ${CONFIRM_TXT}" -n 1 -r + if [[ ${REPLY} =~ ^[Yy]$ ]] + then + # Get current hash and see if it already has a tag + make_log "debug Grabbing commit" + GIT_COMMIT=$(git -C ${REPO_DIR} rev-parse HEAD) + make_log "debug Determining if needs tag" + NEEDS_TAG=$(git -C ${REPO_DIR} describe --contains ${GIT_COMMIT}) + # Only tag if no tag already (would be better if the git describe command above could have a silent option) + if [[ -z "$NEEDS_TAG" ]]; then + make_log "debug Tagged with ${CUR_VERSION} (Ignoring fatal:cannot describe - this means commit is untagged) " + git -C ${PROJECT_DIR} tag ${CUR_VERSION} + make_log "debug Pushing tag to ${MAIN_BRANCH}" + git -C ${PROJECT_DIR} push --tags origin ${MAIN_BRANCH} + else + make_log "debug Already a tag on this commit" + fi + else + make_log "info Aborted tag." + fi +} + + +announce_section "Handling command '${CMD}'..." +if [[ ${CMD} == 'bump' ]] +then + do_bump +elif [[ ${CMD} == 'pull' ]] +then + do_pull +elif [[ ${CMD} == 'push' ]] +then + do_tag_and_push +else + make_log "info No command matched (push|pull|bump)" +fi \ No newline at end of file diff --git a/admin/admin-functions.sh b/admin/admin-functions.sh new file mode 100644 index 0000000..2abb3d1 --- /dev/null +++ b/admin/admin-functions.sh @@ -0,0 +1,251 @@ +#!/usr/bin/env bash +#/ Description: +#/ - log: a simple terminal logger that timestamps messages +#/ - confirm_branch: a way to automatically check the current branch is the desired branch to perform an operation +#/ - bump_version: a means of taking a semver version and bumping it +#/ Usage: +#/ -- Logging +#/ - for INFO and up, load the file as such: +#/ >>> . common.sh +#/ - for DEBUG and up, load the file as such: +#/ >>> DEBUG_LOG=1 +#/ >>> . common.sh +#/ -- Version Bumping +#/ - patch-level +#/ >>> NEW_VERSION=$(version_bump path/to/__init__.py) +#/ or +#/ >>> NEW_VERSION=$(version_bump path/to/__init__.py patch) +#/ - minor-level +#/ >>> NEW_VERSION=$(version_bump path/to/__init__.py minor) +#/ - major-level +#/ >>> NEW_VERSION=$(version_bump path/to/__init__.py major) + +# ------------------------------------------ +# COMMON COLOR CODES +# ------------------------------------------ +# Color codes (Note instead of '\e' it's '\x1B', as Macs are weird and don't support \e by default +# Update to above; apparently \033 also works? Linux is the only concern atm. Keeping this for future ref +GRN="\033[0;32m" +BLU="\033[0;34m" +YLW="\033[0;33m" +RED="\033[0;31m" +PRP="\033[1;35m" +RST="\033[0m" + +# ------------------------------------------ +# VARIABLES EXPECTED +# ------------------------------------------ + +CUR_VERSION="" +NEW_VERSION="" +CUR_UPDATE_DATE="" +NEW_UPDATE_DATE="" +CURRENT_BRANCH="" +TARGET_BRANCH="" + +upper () { + # Cast string to uppercase + echo "${1}"|awk '{print toupper($0)}' +} + +make_log () { + # Logs steps + # Example: + # >>> make_log "DEBUG doing something" + MSG_TYPE=$(upper "$(echo $*|cut -d" " -f1)") + MSG=$(echo "$*"|cut -d" " -f2-) + # Determine log color + case ${MSG_TYPE} in + DEBUG) + COLOR=${GRN} + ;; + INFO) + COLOR=${BLU} + ;; + WARN) + COLOR=${YLW} + ;; + ERROR) + COLOR=${RED} + ;; + *) + COLOR=${PRP} + ;; + esac + # Print debug only when DEBUG_LOG is not 1 + [[ ${MSG_TYPE} == "DEBUG" ]] && [[ ${DEBUG_LOG} -ne 1 ]] && return + [[ ${MSG_TYPE} == "INFO" ]] && MSG_TYPE="INFO " # one space for aligning + [[ ${MSG_TYPE} == "WARN" ]] && MSG_TYPE="WARN " # as well + + # print to the terminal if we have one + test -t 1 && printf "${COLOR} [${MSG_TYPE}]${RST} `date "+%Y-%m-%d_%H:%M:%S %Z"` [@${HOSTNAME}] [$$] ""${COLOR}${MSG}${RST}\n" +} + +# This basically alerts the user that the log has been initiated +make_log "debug Logging initiated..." + +confirm_branch () { + CURRENT_BRANCH=$(git branch | sed -n '/\* /s///p') + TARGET_BRANCH="${1:-master}" + + if [[ ! "${CURRENT_BRANCH}" == "${TARGET_BRANCH}" ]] + then + make_log "error Script aborted. Branch must be set on '${TARGET_BRANCH}'. Current branch is '${CURRENT_BRANCH}'" && exit 1 || return 1 + fi +} + +get_version () { + VERSION_PATH="${1}" + make_log "debug Examining file at path ${VERSION_PATH}..." + if [[ -f "${VERSION_PATH}" ]]; then + CUR_VERSION=$(cat "${VERSION_PATH}" | grep -n 'version.*' -m 1 | awk -F "'|\"" {'print $2'}) + else + make_log "error The file ${RED}${VERSION_PATH}${RESET} doesn't seem to exist. Exiting script..." + exit 1 + fi + make_log "info Got current version: ${CUR_VERSION} and level: ${LEVEL}" +} + +bump_version () { + #/ Takes in a filepath to the python file containing a line with __version__ = '...' + #/ and a level =(patch, minor, or major) + #/ Splits the version according to semver (major.minor.patch) + #/ Increments the version based on level provided + #/ Then outputs the new version + VERSION_PATH="${1}" + LEVEL=${2:-patch} + make_log "debug Getting current version..." + get_version ${VERSION_PATH} + # Replace . with space so can split into an array + make_log "debug Got: ${CUR_VERSION}" + VERSION_BITS=(${CUR_VERSION//./ }) + + # Get number parts and increase last one by 1 + VNUM1=${VERSION_BITS[0]} + VNUM2=${VERSION_BITS[1]} + VNUM3=${VERSION_BITS[2]} + make_log "debug Broken out to: ${VNUM1} ${VNUM2} ${VNUM3}" + + if [[ "${LEVEL}" == "patch" || "${LEVEL}" == "hotfix" ]]; + then + VNUM3=$((VNUM3+1)) + elif [[ "${LEVEL}" == "minor" ]]; + then + VNUM2=$((VNUM2+1)) + VNUM3=0 + elif [[ "${LEVEL}" == "major" ]]; + then + VNUM1=$((VNUM1+1)) + VNUM2=0 + VNUM3=0 + else + make_log "error Invalid level selected. Please enter one of major|minor|patch." && exit 1 + fi + #create new tag + NEW_VERSION="${VNUM1}.${VNUM2}.${VNUM3}" + make_log "debug New version: ${NEW_VERSION}" +} + +announce_section () { + # Makes sections easier to see in output + SECTION_BRK="==============================" + SECTION="${1}" + COLOR=${2:-${BLU}} + printf "${COLOR}${SECTION_BRK}\n${SECTION}\n${SECTION_BRK}${RST}\n" +} + +arg_parse() { + # Parses arguments from command line + POSITIONAL=() + while [[ $# -gt 0 ]] + do + key="$1" + case ${key} in + -v|--version) # Print script name & version + announce_section "${NAME}\n${VERSION}" + exit 0 + ;; + -c|--cmd) + CMD=${2:-bump} + make_log "debug Received command: ${CMD}" + shift # past argument + shift # past value + ;; + -d|--debug) + make_log "debug Setting debug to TRUE." + DEBUG_LOG=1 + shift # past argument + ;; + -l|--level) + LEVEL=${2:-patch} + make_log "debug Received level argument: ${LEVEL}" + shift # past argument + shift # past value + ;; + *) # unknown option + POSITIONAL+=("$1") # save it in an array for later + shift # past argument + ;; + esac + done + set -- "${POSITIONAL[@]}" # restore positional parameters + # Check for unknown arguments passed in (if positional isn't empty) + [[ ! -z "${POSITIONAL}" ]] && echo "Unknown args passed: ${POSITIONAL[@]}" && exit 1 +} + +parse_yaml() { + # Fork of https://github.com/jasperes/bash-yaml + local yaml_file=$1 + local prefix=$2 + local s + local w + local fs + + s='[[:space:]]*' + w='[a-zA-Z0-9_.-]*' + fs="$(echo @|tr @ '\034')" + + ( + sed -e '/- [^\“]'"[^\']"'.*: /s|\([ ]*\)- \([[:space:]]*\)|\1-\'$'\n'' \1\2|g' | + + sed -ne '/^--/s|--||g; s|\"|\\\"|g; s/[[:space:]]*$//g;' \ + -e "/#.*[\"\']/!s| #.*||g; /^#/s|#.*||g;" \ + -e "s|^\($s\)\($w\)$s:$s\"\(.*\)\"$s\$|\1$fs\2$fs\3|p" \ + -e "s|^\($s\)\($w\)${s}[:-]$s\(.*\)$s\$|\1$fs\2$fs\3|p" | + + awk -F"$fs" '{ + indent = length($1)/2; + if (length($2) == 0) { conj[indent]="+";} else {conj[indent]="";} + vname[indent] = $2; + for (i in vname) {if (i > indent) {delete vname[i]}} + if (length($3) > 0) { + vn=""; for (i=0; i=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "filelock" +version = "3.18.0" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de"}, + {file = "filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.10)", "diff-cover (>=9.2.1)", "pytest (>=8.3.4)", "pytest-asyncio (>=0.25.2)", "pytest-cov (>=6)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.28.1)"] +typing = ["typing-extensions (>=4.12.2) ; python_version < \"3.11\""] + +[[package]] +name = "flake8" +version = "7.2.0" +description = "the modular source code checker: pep8 pyflakes and co" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "flake8-7.2.0-py2.py3-none-any.whl", hash = "sha256:93b92ba5bdb60754a6da14fa3b93a9361fd00a59632ada61fd7b130436c40343"}, + {file = "flake8-7.2.0.tar.gz", hash = "sha256:fa558ae3f6f7dbf2b4f22663e5343b6b6023620461f8d4ff2019ef4b5ee70426"}, +] + +[package.dependencies] +mccabe = ">=0.7.0,<0.8.0" +pycodestyle = ">=2.13.0,<2.14.0" +pyflakes = ">=3.3.0,<3.4.0" + +[[package]] +name = "identify" +version = "2.6.12" +description = "File identification library for Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2"}, + {file = "identify-2.6.12.tar.gz", hash = "sha256:d8de45749f1efb108badef65ee8386f0f7bb19a7f26185f74de6367bffbaf0e6"}, +] + +[package.extras] +license = ["ukkonen"] + +[[package]] +name = "idna" +version = "3.10" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, + {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + +[[package]] +name = "iniconfig" +version = "2.1.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760"}, + {file = "iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7"}, +] + +[[package]] +name = "loguru" +version = "0.6.0" +description = "Python logging made (stupidly) simple" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "loguru-0.6.0-py3-none-any.whl", hash = "sha256:4e2414d534a2ab57573365b3e6d0234dfb1d84b68b7f3b948e6fb743860a77c3"}, + {file = "loguru-0.6.0.tar.gz", hash = "sha256:066bd06758d0a513e9836fd9c6b5a75bfb3fd36841f4b996bc60b547a309d41c"}, +] + +[package.dependencies] +colorama = {version = ">=0.3.4", markers = "sys_platform == \"win32\""} +win32-setctime = {version = ">=1.0.0", markers = "sys_platform == \"win32\""} + +[package.extras] +dev = ["Sphinx (>=4.1.1) ; python_version >= \"3.6\"", "black (>=19.10b0) ; python_version >= \"3.6\"", "colorama (>=0.3.4)", "docutils (==0.16)", "flake8 (>=3.7.7)", "isort (>=5.1.1) ; python_version >= \"3.6\"", "pytest (>=4.6.2)", "pytest-cov (>=2.7.1)", "sphinx-autobuild (>=0.7.1) ; python_version >= \"3.6\"", "sphinx-rtd-theme (>=0.4.3) ; python_version >= \"3.6\"", "tox (>=3.9.0)"] + +[[package]] +name = "mccabe" +version = "0.7.0" +description = "McCabe checker, plugin for flake8" +optional = false +python-versions = ">=3.6" +groups = ["dev"] +files = [ + {file = "mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e"}, + {file = "mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325"}, +] + +[[package]] +name = "nodeenv" +version = "1.9.1" +description = "Node.js virtual environment builder" +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +groups = ["dev"] +files = [ + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, +] + +[[package]] +name = "numpy" +version = "2.2.6" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +files = [ + {file = "numpy-2.2.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b412caa66f72040e6d268491a59f2c43bf03eb6c96dd8f0307829feb7fa2b6fb"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e41fd67c52b86603a91c1a505ebaef50b3314de0213461c7a6e99c9a3beff90"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:37e990a01ae6ec7fe7fa1c26c55ecb672dd98b19c3d0e1d1f326fa13cb38d163"}, + {file = "numpy-2.2.6-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:5a6429d4be8ca66d889b7cf70f536a397dc45ba6faeb5f8c5427935d9592e9cf"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:efd28d4e9cd7d7a8d39074a4d44c63eda73401580c5c76acda2ce969e0a38e83"}, + {file = "numpy-2.2.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fc7b73d02efb0e18c000e9ad8b83480dfcd5dfd11065997ed4c6747470ae8915"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:74d4531beb257d2c3f4b261bfb0fc09e0f9ebb8842d82a7b4209415896adc680"}, + {file = "numpy-2.2.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8fc377d995680230e83241d8a96def29f204b5782f371c532579b4f20607a289"}, + {file = "numpy-2.2.6-cp310-cp310-win32.whl", hash = "sha256:b093dd74e50a8cba3e873868d9e93a85b78e0daf2e98c6797566ad8044e8363d"}, + {file = "numpy-2.2.6-cp310-cp310-win_amd64.whl", hash = "sha256:f0fd6321b839904e15c46e0d257fdd101dd7f530fe03fd6359c1ea63738703f3"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f9f1adb22318e121c5c69a09142811a201ef17ab257a1e66ca3025065b7f53ae"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c820a93b0255bc360f53eca31a0e676fd1101f673dda8da93454a12e23fc5f7a"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3d70692235e759f260c3d837193090014aebdf026dfd167834bcba43e30c2a42"}, + {file = "numpy-2.2.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:481b49095335f8eed42e39e8041327c05b0f6f4780488f61286ed3c01368d491"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b64d8d4d17135e00c8e346e0a738deb17e754230d7e0810ac5012750bbd85a5a"}, + {file = "numpy-2.2.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba10f8411898fc418a521833e014a77d3ca01c15b0c6cdcce6a0d2897e6dbbdf"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd48227a919f1bafbdda0583705e547892342c26fb127219d60a5c36882609d1"}, + {file = "numpy-2.2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9551a499bf125c1d4f9e250377c1ee2eddd02e01eac6644c080162c0c51778ab"}, + {file = "numpy-2.2.6-cp311-cp311-win32.whl", hash = "sha256:0678000bb9ac1475cd454c6b8c799206af8107e310843532b04d49649c717a47"}, + {file = "numpy-2.2.6-cp311-cp311-win_amd64.whl", hash = "sha256:e8213002e427c69c45a52bbd94163084025f533a55a59d6f9c5b820774ef3303"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:41c5a21f4a04fa86436124d388f6ed60a9343a6f767fced1a8a71c3fbca038ff"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de749064336d37e340f640b05f24e9e3dd678c57318c7289d222a8a2f543e90c"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:894b3a42502226a1cac872f840030665f33326fc3dac8e57c607905773cdcde3"}, + {file = "numpy-2.2.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:71594f7c51a18e728451bb50cc60a3ce4e6538822731b2933209a1f3614e9282"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2618db89be1b4e05f7a1a847a9c1c0abd63e63a1607d892dd54668dd92faf87"}, + {file = "numpy-2.2.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd83c01228a688733f1ded5201c678f0c53ecc1006ffbc404db9f7a899ac6249"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:37c0ca431f82cd5fa716eca9506aefcabc247fb27ba69c5062a6d3ade8cf8f49"}, + {file = "numpy-2.2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fe27749d33bb772c80dcd84ae7e8df2adc920ae8297400dabec45f0dedb3f6de"}, + {file = "numpy-2.2.6-cp312-cp312-win32.whl", hash = "sha256:4eeaae00d789f66c7a25ac5f34b71a7035bb474e679f410e5e1a94deb24cf2d4"}, + {file = "numpy-2.2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c1f9540be57940698ed329904db803cf7a402f3fc200bfe599334c9bd84a40b2"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0811bb762109d9708cca4d0b13c4f67146e3c3b7cf8d34018c722adb2d957c84"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:287cc3162b6f01463ccd86be154f284d0893d2b3ed7292439ea97eafa8170e0b"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:f1372f041402e37e5e633e586f62aa53de2eac8d98cbfb822806ce4bbefcb74d"}, + {file = "numpy-2.2.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:55a4d33fa519660d69614a9fad433be87e5252f4b03850642f88993f7b2ca566"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f92729c95468a2f4f15e9bb94c432a9229d0d50de67304399627a943201baa2f"}, + {file = "numpy-2.2.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bc23a79bfabc5d056d106f9befb8d50c31ced2fbc70eedb8155aec74a45798f"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e3143e4451880bed956e706a3220b4e5cf6172ef05fcc397f6f36a550b1dd868"}, + {file = "numpy-2.2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b4f13750ce79751586ae2eb824ba7e1e8dba64784086c98cdbbcc6a42112ce0d"}, + {file = "numpy-2.2.6-cp313-cp313-win32.whl", hash = "sha256:5beb72339d9d4fa36522fc63802f469b13cdbe4fdab4a288f0c441b74272ebfd"}, + {file = "numpy-2.2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b0544343a702fa80c95ad5d3d608ea3599dd54d4632df855e4c8d24eb6ecfa1c"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0bca768cd85ae743b2affdc762d617eddf3bcf8724435498a1e80132d04879e6"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fc0c5673685c508a142ca65209b4e79ed6740a4ed6b2267dbba90f34b0b3cfda"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:5bd4fc3ac8926b3819797a7c0e2631eb889b4118a9898c84f585a54d475b7e40"}, + {file = "numpy-2.2.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:fee4236c876c4e8369388054d02d0e9bb84821feb1a64dd59e137e6511a551f8"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e1dda9c7e08dc141e0247a5b8f49cf05984955246a327d4c48bda16821947b2f"}, + {file = "numpy-2.2.6-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f447e6acb680fd307f40d3da4852208af94afdfab89cf850986c3ca00562f4fa"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:389d771b1623ec92636b0786bc4ae56abafad4a4c513d36a55dce14bd9ce8571"}, + {file = "numpy-2.2.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8e9ace4a37db23421249ed236fdcdd457d671e25146786dfc96835cd951aa7c1"}, + {file = "numpy-2.2.6-cp313-cp313t-win32.whl", hash = "sha256:038613e9fb8c72b0a41f025a7e4c3f0b7a1b5d768ece4796b674c8f3fe13efff"}, + {file = "numpy-2.2.6-cp313-cp313t-win_amd64.whl", hash = "sha256:6031dd6dfecc0cf9f668681a37648373bddd6421fff6c66ec1624eed0180ee06"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0b605b275d7bd0c640cad4e5d30fa701a8d59302e127e5f79138ad62762c3e3d"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:7befc596a7dc9da8a337f79802ee8adb30a552a94f792b9c9d18c840055907db"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce47521a4754c8f4593837384bd3424880629f718d87c5d44f8ed763edd63543"}, + {file = "numpy-2.2.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d042d24c90c41b54fd506da306759e06e568864df8ec17ccc17e9e884634fd00"}, + {file = "numpy-2.2.6.tar.gz", hash = "sha256:e29554e2bef54a90aa5cc07da6ce955accb83f21ab5de01a62c8478897b264fd"}, +] + +[[package]] +name = "opencv-python" +version = "4.11.0.86" +description = "Wrapper package for OpenCV python bindings." +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "opencv-python-4.11.0.86.tar.gz", hash = "sha256:03d60ccae62304860d232272e4a4fda93c39d595780cb40b161b310244b736a4"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:432f67c223f1dc2824f5e73cdfcd9db0efc8710647d4e813012195dc9122a52a"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9d05ef13d23fe97f575153558653e2d6e87103995d54e6a35db3f282fe1f9c66"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b92ae2c8852208817e6776ba1ea0d6b1e0a1b5431e971a2a0ddd2a8cc398202"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6b02611523803495003bd87362db3e1d2a0454a6a63025dc6658a9830570aa0d"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:810549cb2a4aedaa84ad9a1c92fbfdfc14090e2749cedf2c1589ad8359aa169b"}, + {file = "opencv_python-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:085ad9b77c18853ea66283e98affefe2de8cc4c1f43eda4c100cf9b2721142ec"}, +] + +[package.dependencies] +numpy = [ + {version = ">=1.26.0", markers = "python_version >= \"3.12\""}, + {version = ">=1.23.5", markers = "python_version == \"3.11\""}, + {version = ">=1.21.4", markers = "python_version == \"3.10\" and platform_system == \"Darwin\""}, + {version = ">=1.21.2", markers = "platform_system != \"Darwin\" and python_version == \"3.10\""}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pillow" +version = "11.2.1" +description = "Python Imaging Library (Fork)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047"}, + {file = "pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c"}, + {file = "pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97"}, + {file = "pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579"}, + {file = "pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d"}, + {file = "pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad"}, + {file = "pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:35ca289f712ccfc699508c4658a1d14652e8033e9b69839edf83cbdd0ba39e70"}, + {file = "pillow-11.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0409af9f829f87a2dfb7e259f78f317a5351f2045158be321fd135973fff7bf"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d4e5c5edee874dce4f653dbe59db7c73a600119fbea8d31f53423586ee2aafd7"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b93a07e76d13bff9444f1a029e0af2964e654bfc2e2c2d46bfd080df5ad5f3d8"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e6def7eed9e7fa90fde255afaf08060dc4b343bbe524a8f69bdd2a2f0018f600"}, + {file = "pillow-11.2.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8f4f3724c068be008c08257207210c138d5f3731af6c155a81c2b09a9eb3a788"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a0a6709b47019dff32e678bc12c63008311b82b9327613f534e496dacaefb71e"}, + {file = "pillow-11.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f6b0c664ccb879109ee3ca702a9272d877f4fcd21e5eb63c26422fd6e415365e"}, + {file = "pillow-11.2.1-cp311-cp311-win32.whl", hash = "sha256:cc5d875d56e49f112b6def6813c4e3d3036d269c008bf8aef72cd08d20ca6df6"}, + {file = "pillow-11.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:0f5c7eda47bf8e3c8a283762cab94e496ba977a420868cb819159980b6709193"}, + {file = "pillow-11.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:4d375eb838755f2528ac8cbc926c3e31cc49ca4ad0cf79cff48b20e30634a4a7"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:78afba22027b4accef10dbd5eed84425930ba41b3ea0a86fa8d20baaf19d807f"}, + {file = "pillow-11.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78092232a4ab376a35d68c4e6d5e00dfd73454bd12b230420025fbe178ee3b0b"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25a5f306095c6780c52e6bbb6109624b95c5b18e40aab1c3041da3e9e0cd3e2d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c7b29dbd4281923a2bfe562acb734cee96bbb129e96e6972d315ed9f232bef4"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3e645b020f3209a0181a418bffe7b4a93171eef6c4ef6cc20980b30bebf17b7d"}, + {file = "pillow-11.2.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:b2dbea1012ccb784a65349f57bbc93730b96e85b42e9bf7b01ef40443db720b4"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:da3104c57bbd72948d75f6a9389e6727d2ab6333c3617f0a89d72d4940aa0443"}, + {file = "pillow-11.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:598174aef4589af795f66f9caab87ba4ff860ce08cd5bb447c6fc553ffee603c"}, + {file = "pillow-11.2.1-cp312-cp312-win32.whl", hash = "sha256:1d535df14716e7f8776b9e7fee118576d65572b4aad3ed639be9e4fa88a1cad3"}, + {file = "pillow-11.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:14e33b28bf17c7a38eede290f77db7c664e4eb01f7869e37fa98a5aa95978941"}, + {file = "pillow-11.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:21e1470ac9e5739ff880c211fc3af01e3ae505859392bf65458c224d0bf283eb"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fdec757fea0b793056419bca3e9932eb2b0ceec90ef4813ea4c1e072c389eb28"}, + {file = "pillow-11.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b0e130705d568e2f43a17bcbe74d90958e8a16263868a12c3e0d9c8162690830"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bdb5e09068332578214cadd9c05e3d64d99e0e87591be22a324bdbc18925be0"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d189ba1bebfbc0c0e529159631ec72bb9e9bc041f01ec6d3233d6d82eb823bc1"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:191955c55d8a712fab8934a42bfefbf99dd0b5875078240943f913bb66d46d9f"}, + {file = "pillow-11.2.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:ad275964d52e2243430472fc5d2c2334b4fc3ff9c16cb0a19254e25efa03a155"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:750f96efe0597382660d8b53e90dd1dd44568a8edb51cb7f9d5d918b80d4de14"}, + {file = "pillow-11.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fe15238d3798788d00716637b3d4e7bb6bde18b26e5d08335a96e88564a36b6b"}, + {file = "pillow-11.2.1-cp313-cp313-win32.whl", hash = "sha256:3fe735ced9a607fee4f481423a9c36701a39719252a9bb251679635f99d0f7d2"}, + {file = "pillow-11.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:74ee3d7ecb3f3c05459ba95eed5efa28d6092d751ce9bf20e3e253a4e497e691"}, + {file = "pillow-11.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:5119225c622403afb4b44bad4c1ca6c1f98eed79db8d3bc6e4e160fc6339d66c"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:8ce2e8411c7aaef53e6bb29fe98f28cd4fbd9a1d9be2eeea434331aac0536b22"}, + {file = "pillow-11.2.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:9ee66787e095127116d91dea2143db65c7bb1e232f617aa5957c0d9d2a3f23a7"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9622e3b6c1d8b551b6e6f21873bdcc55762b4b2126633014cea1803368a9aa16"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:63b5dff3a68f371ea06025a1a6966c9a1e1ee452fc8020c2cd0ea41b83e9037b"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:31df6e2d3d8fc99f993fd253e97fae451a8db2e7207acf97859732273e108406"}, + {file = "pillow-11.2.1-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:062b7a42d672c45a70fa1f8b43d1d38ff76b63421cbbe7f88146b39e8a558d91"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4eb92eca2711ef8be42fd3f67533765d9fd043b8c80db204f16c8ea62ee1a751"}, + {file = "pillow-11.2.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f91ebf30830a48c825590aede79376cb40f110b387c17ee9bd59932c961044f9"}, + {file = "pillow-11.2.1-cp313-cp313t-win32.whl", hash = "sha256:e0b55f27f584ed623221cfe995c912c61606be8513bfa0e07d2c674b4516d9dd"}, + {file = "pillow-11.2.1-cp313-cp313t-win_amd64.whl", hash = "sha256:36d6b82164c39ce5482f649b437382c0fb2395eabc1e2b1702a6deb8ad647d6e"}, + {file = "pillow-11.2.1-cp313-cp313t-win_arm64.whl", hash = "sha256:225c832a13326e34f212d2072982bb1adb210e0cc0b153e688743018c94a2681"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:7491cf8a79b8eb867d419648fff2f83cb0b3891c8b36da92cc7f1931d46108c8"}, + {file = "pillow-11.2.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b02d8f9cb83c52578a0b4beadba92e37d83a4ef11570a8688bbf43f4ca50909"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:014ca0050c85003620526b0ac1ac53f56fc93af128f7546623cc8e31875ab928"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3692b68c87096ac6308296d96354eddd25f98740c9d2ab54e1549d6c8aea9d79"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:f781dcb0bc9929adc77bad571b8621ecb1e4cdef86e940fe2e5b5ee24fd33b35"}, + {file = "pillow-11.2.1-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:2b490402c96f907a166615e9a5afacf2519e28295f157ec3a2bb9bd57de638cb"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:dd6b20b93b3ccc9c1b597999209e4bc5cf2853f9ee66e3fc9a400a78733ffc9a"}, + {file = "pillow-11.2.1-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:4b835d89c08a6c2ee7781b8dd0a30209a8012b5f09c0a665b65b0eb3560b6f36"}, + {file = "pillow-11.2.1-cp39-cp39-win32.whl", hash = "sha256:b10428b3416d4f9c61f94b494681280be7686bda15898a3a9e08eb66a6d92d67"}, + {file = "pillow-11.2.1-cp39-cp39-win_amd64.whl", hash = "sha256:6ebce70c3f486acf7591a3d73431fa504a4e18a9b97ff27f5f47b7368e4b9dd1"}, + {file = "pillow-11.2.1-cp39-cp39-win_arm64.whl", hash = "sha256:c27476257b2fdcd7872d54cfd119b3a9ce4610fb85c8e32b70b42e3680a29a1e"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193"}, + {file = "pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:80f1df8dbe9572b4b7abdfa17eb5d78dd620b1d55d9e25f834efdbee872d3aed"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ea926cfbc3957090becbcbbb65ad177161a2ff2ad578b5a6ec9bb1e1cd78753c"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738db0e0941ca0376804d4de6a782c005245264edaa253ffce24e5a15cbdc7bd"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9db98ab6565c69082ec9b0d4e40dd9f6181dab0dd236d26f7a50b8b9bfbd5076"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:036e53f4170e270ddb8797d4c590e6dd14d28e15c7da375c18978045f7e6c37b"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:14f73f7c291279bd65fda51ee87affd7c1e097709f7fdd0188957a16c264601f"}, + {file = "pillow-11.2.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:208653868d5c9ecc2b327f9b9ef34e0e42a4cdd172c2988fd81d62d2bc9bc044"}, + {file = "pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6"}, +] + +[package.extras] +docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"] +fpx = ["olefile"] +mic = ["olefile"] +test-arrow = ["pyarrow"] +tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout", "trove-classifiers (>=2024.10.12)"] +typing = ["typing-extensions ; python_version < \"3.10\""] +xmp = ["defusedxml"] + +[[package]] +name = "platformdirs" +version = "4.3.8" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "platformdirs-4.3.8-py3-none-any.whl", hash = "sha256:ff7059bb7eb1179e2685604f4aaf157cfd9535242bd23742eadc3c13542139b4"}, + {file = "platformdirs-4.3.8.tar.gz", hash = "sha256:3d512d96e16bcb959a814c9f348431070822a6496326a4be0911c40b5a74c2bc"}, +] + +[package.extras] +docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.1.3)", "sphinx-autodoc-typehints (>=3)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.4)", "pytest-cov (>=6)", "pytest-mock (>=3.14)"] +type = ["mypy (>=1.14.1)"] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pre-commit" +version = "3.8.0" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pre_commit-3.8.0-py2.py3-none-any.whl", hash = "sha256:9a90a53bf82fdd8778d58085faf8d83df56e40dfe18f45b19446e26bf1b3a63f"}, + {file = "pre_commit-3.8.0.tar.gz", hash = "sha256:8bb6494d4a20423842e198980c9ecf9f96607a07ea29549e180eef9ae80fe7af"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + +[[package]] +name = "pycodestyle" +version = "2.13.0" +description = "Python style guide checker" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pycodestyle-2.13.0-py2.py3-none-any.whl", hash = "sha256:35863c5974a271c7a726ed228a14a4f6daf49df369d8c50cd9a6f58a5e143ba9"}, + {file = "pycodestyle-2.13.0.tar.gz", hash = "sha256:c8415bf09abe81d9c7f872502a6eee881fbe85d8763dd5b9924bb0a01d67efae"}, +] + +[[package]] +name = "pyflakes" +version = "3.3.2" +description = "passive checker of Python programs" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyflakes-3.3.2-py2.py3-none-any.whl", hash = "sha256:5039c8339cbb1944045f4ee5466908906180f13cc99cc9949348d10f82a5c32a"}, + {file = "pyflakes-3.3.2.tar.gz", hash = "sha256:6dfd61d87b97fba5dcfaaf781171ac16be16453be6d816147989e7f6e6a9576b"}, +] + +[[package]] +name = "pygments" +version = "2.19.1" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c"}, + {file = "pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pyproject-api" +version = "1.9.1" +description = "API to interact with the python pyproject.toml based projects" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pyproject_api-1.9.1-py3-none-any.whl", hash = "sha256:7d6238d92f8962773dd75b5f0c4a6a27cce092a14b623b811dba656f3b628948"}, + {file = "pyproject_api-1.9.1.tar.gz", hash = "sha256:43c9918f49daab37e302038fc1aed54a8c7a91a9fa935d00b9a485f37e0f5335"}, +] + +[package.dependencies] +packaging = ">=25" +tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} + +[package.extras] +docs = ["furo (>=2024.8.6)", "sphinx-autodoc-typehints (>=3.2)"] +testing = ["covdefaults (>=2.3)", "pytest (>=8.3.5)", "pytest-cov (>=6.1.1)", "pytest-mock (>=3.14)", "setuptools (>=80.3.1)"] + +[[package]] +name = "pysocks" +version = "1.7.1" +description = "A Python SOCKS client module. See https://github.com/Anorov/PySocks for more information." +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +groups = ["main"] +files = [ + {file = "PySocks-1.7.1-py27-none-any.whl", hash = "sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299"}, + {file = "PySocks-1.7.1-py3-none-any.whl", hash = "sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5"}, + {file = "PySocks-1.7.1.tar.gz", hash = "sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"}, +] + +[[package]] +name = "pytest" +version = "8.4.0" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest-8.4.0-py3-none-any.whl", hash = "sha256:f40f825768ad76c0977cbacdf1fd37c6f7a468e460ea6a0636078f8972d4517e"}, + {file = "pytest-8.4.0.tar.gz", hash = "sha256:14d920b48472ea0dbf68e45b96cd1ffda4705f33307dcc86c676c1b5104838a6"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1" +packaging = ">=20" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "6.1.1" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "pytest_cov-6.1.1-py3-none-any.whl", hash = "sha256:bddf29ed2d0ab6f4df17b4c55b0a657287db8684af9c42ea546b21b1041b3dde"}, + {file = "pytest_cov-6.1.1.tar.gz", hash = "sha256:46935f7aaefba760e716c2ebfbe1c216240b9592966e7da99ea8292d4d3e2a0a"}, +] + +[package.dependencies] +coverage = {version = ">=7.5", extras = ["toml"]} +pytest = ">=4.6" + +[package.extras] +testing = ["fields", "hunter", "process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main", "dev"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "requests" +version = "2.32.3" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, + {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset-normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + +[[package]] +name = "tomli" +version = "2.2.1" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, + {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, + {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, + {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, + {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, + {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, + {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, + {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, + {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, + {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, + {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, + {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, + {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, + {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, + {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, + {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, + {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, + {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, +] + +[[package]] +name = "tox" +version = "4.26.0" +description = "tox is a generic virtualenv management and test command line tool" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +files = [ + {file = "tox-4.26.0-py3-none-any.whl", hash = "sha256:75f17aaf09face9b97bd41645028d9f722301e912be8b4c65a3f938024560224"}, + {file = "tox-4.26.0.tar.gz", hash = "sha256:a83b3b67b0159fa58e44e646505079e35a43317a62d2ae94725e0586266faeca"}, +] + +[package.dependencies] +cachetools = ">=5.5.1" +chardet = ">=5.2" +colorama = ">=0.4.6" +filelock = ">=3.16.1" +packaging = ">=24.2" +platformdirs = ">=4.3.6" +pluggy = ">=1.5" +pyproject-api = ">=1.8" +tomli = {version = ">=2.2.1", markers = "python_version < \"3.11\""} +typing-extensions = {version = ">=4.12.2", markers = "python_version < \"3.11\""} +virtualenv = ">=20.31" + +[package.extras] +test = ["devpi-process (>=1.0.2)", "pytest (>=8.3.4)", "pytest-mock (>=3.14)"] + +[[package]] +name = "typing-extensions" +version = "4.14.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +groups = ["dev"] +markers = "python_version == \"3.10\"" +files = [ + {file = "typing_extensions-4.14.0-py3-none-any.whl", hash = "sha256:a1514509136dd0b477638fc68d6a91497af5076466ad0fa6c338e44e359944af"}, + {file = "typing_extensions-4.14.0.tar.gz", hash = "sha256:8676b788e32f02ab42d9e7c61324048ae4c6d844a399eebace3d4979d75ceef4"}, +] + +[[package]] +name = "urllib3" +version = "2.4.0" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813"}, + {file = "urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466"}, +] + +[package.extras] +brotli = ["brotli (>=1.0.9) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=0.8.0) ; platform_python_implementation != \"CPython\""] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "virtualenv" +version = "20.31.2" +description = "Virtual Python Environment builder" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11"}, + {file = "virtualenv-20.31.2.tar.gz", hash = "sha256:e10c0a9d02835e592521be48b332b6caee6887f332c111aa79a09b9e79efc2af"}, +] + +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + +[package.extras] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8) ; platform_python_implementation == \"PyPy\" or platform_python_implementation == \"GraalVM\" or platform_python_implementation == \"CPython\" and sys_platform == \"win32\" and python_version >= \"3.13\"", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10) ; platform_python_implementation == \"CPython\""] + +[[package]] +name = "win32-setctime" +version = "1.2.0" +description = "A small Python utility to set file creation time on Windows" +optional = false +python-versions = ">=3.5" +groups = ["main"] +markers = "sys_platform == \"win32\"" +files = [ + {file = "win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390"}, + {file = "win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0"}, +] + +[package.extras] +dev = ["black (>=19.3b0) ; python_version >= \"3.6\"", "pytest (>=4.6.2)"] + +[extras] +test = [] + +[metadata] +lock-version = "2.1" +python-versions = ">=3.10" +content-hash = "4db3eb352d75aec1109ce5ed8931a69d47e363a9cf9d802729289890d2f6b732" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..6c33fd6 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,36 @@ +[build-system] +requires = ["poetry-core>=1.0.0"] +build-backend = "poetry.core.masonry.api" + +[tool.poetry] +name = "reolink_api" +version = '1.1.2' +description = "Unofficial Reolink API fork for personal use in Python 3.10+" +authors = ["bobrock "] +license = "GPL-3.0" +readme = 'README.md' +repository = 'https://github.com/barretobrock/reolink-python-api' +packages = [ + { include = 'reolink_api' }, +] +include = ["CHANGELOG.md"] + +[tool.poetry.dependencies] +python = ">=3.10" +Pillow = ">=9" +PySocks = ">=1" +PyYAML = "^6.0" +loguru = ">=0.6" +numpy = "^2" +opencv-python = "^4" +requests = ">=2.32.0" + +[tool.poetry.group.dev.dependencies] +pre-commit = "^3" +pytest = "^8" +pytest-cov = "^6" +flake8 = "^7" +tox = "^4" + +[tool.poetry.extras] +test = ["pytest"] diff --git a/reolink_api/APIHandler.py b/reolink_api/APIHandler.py index 093fd2c..f8f5896 100644 --- a/reolink_api/APIHandler.py +++ b/reolink_api/APIHandler.py @@ -1,17 +1,23 @@ +from typing import ( + Dict, + List, + Union +) import requests +from loguru import logger +from reolink_api.alarm import AlarmAPIMixin +from reolink_api.device import DeviceAPIMixin +from reolink_api.display import DisplayAPIMixin +from reolink_api.download import DownloadAPIMixin +from reolink_api.image import ImageAPIMixin +from reolink_api.motion import MotionAPIMixin +from reolink_api.network import NetworkAPIMixin +from reolink_api.ptz import PtzAPIMixin +from reolink_api.recording import RecordingAPIMixin from reolink_api.resthandle import Request -from .alarm import AlarmAPIMixin -from .device import DeviceAPIMixin -from .display import DisplayAPIMixin -from .download import DownloadAPIMixin -from .image import ImageAPIMixin -from .motion import MotionAPIMixin -from .network import NetworkAPIMixin -from .ptz import PtzAPIMixin -from .recording import RecordingAPIMixin -from .system import SystemAPIMixin -from .user import UserAPIMixin -from .zoom import ZoomAPIMixin +from reolink_api.system import SystemAPIMixin +from reolink_api.user import UserAPIMixin +from reolink_api.zoom import ZoomAPIMixin class APIHandler(AlarmAPIMixin, @@ -35,7 +41,7 @@ class APIHandler(AlarmAPIMixin, All Code will try to follow the PEP 8 standard as described here: https://www.python.org/dev/peps/pep-0008/ """ - def __init__(self, ip: str, username: str, password: str, https=False, **kwargs): + def __init__(self, ip: str, username: str, password: str, https: bool = False, **kwargs): """ Initialise the Camera API Handler (maps api calls into python) :param ip: @@ -63,21 +69,22 @@ def login(self) -> bool: body = [{"cmd": "Login", "action": 0, "param": {"User": {"userName": self.username, "password": self.password}}}] param = {"cmd": "Login", "token": "null"} - response = Request.post(self.url, data=body, params=param) + response = Request.post(self.url, data=body, params=param) # type: requests.Response if response is not None: data = response.json()[0] code = data["code"] if int(code) == 0: self.token = data["value"]["Token"]["name"] - print("Login success") + logger.debug("Login success") return True - print(self.token) + logger.debug(self.token) return False else: - print("Failed to login\nStatus Code:", response.status_code) + # Response object was NoneType (empty) + logger.warning("Failed to login\nError in request.") return False except Exception as e: - print("Error Login\n", e) + logger.error("Error Login\n", e) raise def logout(self) -> bool: @@ -88,13 +95,12 @@ def logout(self) -> bool: try: data = [{"cmd": "Logout", "action": 0}] self._execute_command('Logout', data) - # print(ret) return True except Exception as e: - print("Error Logout\n", e) + logger.error("Error Logout\n", e) return False - def _execute_command(self, command, data, multi=False): + def _execute_command(self, command: str, data: List[Dict], multi: bool = False) -> Union[bool, Dict]: """ Send a POST request to the IP camera with given data. :param command: name of the command to send @@ -122,12 +128,12 @@ def _execute_command(self, command, data, multi=False): f.write(req.content) return True else: - print(f'Error received: {req.status_code}') + logger.error(f'Error received: {req.status_code}') return False else: response = Request.post(self.url, data=data, params=params) - return response.json() + return response.json() except Exception as e: - print(f"Command {command} failed: {e}") + logger.error(f"Command {command} failed: {e}") raise diff --git a/reolink_api/ConfigHandler.py b/reolink_api/ConfigHandler.py index 37f255e..0d1824a 100644 --- a/reolink_api/ConfigHandler.py +++ b/reolink_api/ConfigHandler.py @@ -1,4 +1,9 @@ import io +from typing import ( + Dict, + Optional +) +from loguru import logger import yaml @@ -6,11 +11,11 @@ class ConfigHandler: camera_settings = {} @staticmethod - def load() -> yaml or None: + def load() -> Optional[Dict]: try: stream = io.open("config.yml", 'r', encoding='utf8') data = yaml.safe_load(stream) return data except Exception as e: - print("Config Property Error\n", e) + logger.error("Config Property Error\n", e) return None diff --git a/reolink_api/RtspClient.py b/reolink_api/RtspClient.py index 655f95f..c84279a 100644 --- a/reolink_api/RtspClient.py +++ b/reolink_api/RtspClient.py @@ -1,5 +1,6 @@ import os from threading import ThreadError +from loguru import logger import cv2 from reolink_api.util import threaded @@ -12,7 +13,8 @@ class RtspClient: - https://stackoverflow.com/questions/55828451/video-streaming-from-ip-camera-in-python-using-opencv-cv2-videocapture """ - def __init__(self, ip, username, password, port=554, profile="main", use_udp=True, callback=None, **kwargs): + def __init__(self, ip: str, username: str, password: str, port: int = 554, profile: str = "main", + use_udp: bool = True, callback=None, **kwargs): """ RTSP client is used to retrieve frames from the camera in a stream @@ -34,12 +36,8 @@ def __init__(self, ip, username, password, port=554, profile="main", use_udp=Tru self.password = password self.port = port self.proxy = kwargs.get("proxies") - self.url = "rtsp://" + self.username + ":" + self.password + "@" + \ - self.ip + ":" + str(self.port) + "//h264Preview_01_" + profile - if use_udp: - capture_options = capture_options + 'udp' - else: - capture_options = capture_options + 'tcp' + self.url = f'rtsp://{self.username}:{self.password}@{self.ip}:{self.port}//h264Preview_01_{profile}' + capture_options += 'udp' if use_udp else 'tcp' os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = capture_options @@ -50,7 +48,7 @@ def _open_video_capture(self): # To CAP_FFMPEG or not To ? self.capture = cv2.VideoCapture(self.url, cv2.CAP_FFMPEG) - def _stream_blocking(self): + def _stream_blocking(self) -> object: while True: try: if self.capture.isOpened(): @@ -58,11 +56,11 @@ def _stream_blocking(self): if ret: yield frame else: - print("stream closed") + logger.info("stream closed") self.capture.release() return except Exception as e: - print(e) + logger.error(e) self.capture.release() return @@ -75,17 +73,17 @@ def _stream_non_blocking(self): if ret: self.callback(frame) else: - print("stream is closed") + logger.info("stream is closed") self.stop_stream() except ThreadError as e: - print(e) + logger.error(e) self.stop_stream() def stop_stream(self): self.capture.release() self.thread_cancelled = True - def open_stream(self): + def open_stream(self) -> object: """ Opens OpenCV Video stream and returns the result according to the OpenCV documentation https://docs.opencv.org/3.4/d8/dfe/classcv_1_1VideoCapture.html#a473055e77dd7faa4d26d686226b292c1 @@ -95,7 +93,7 @@ def open_stream(self): if self.capture is None or not self.capture.isOpened(): self._open_video_capture() - print("opening stream") + logger.info("opening stream") if self.callback is None: return self._stream_blocking() diff --git a/reolink_api/__init__.py b/reolink_api/__init__.py index 6d78770..87cbc5c 100644 --- a/reolink_api/__init__.py +++ b/reolink_api/__init__.py @@ -1,4 +1,5 @@ from .APIHandler import APIHandler from .Camera import Camera -__version__ = "0.1.1" +__version__ = '1.1.2' +__update_date__ = '2025-06-02_18:34:33' diff --git a/reolink_api/alarm.py b/reolink_api/alarm.py index 2f48efb..f5c996f 100644 --- a/reolink_api/alarm.py +++ b/reolink_api/alarm.py @@ -1,7 +1,13 @@ +from typing import ( + Dict, + Union +) + + class AlarmAPIMixin: """API calls for getting device alarm information.""" - def get_alarm_motion(self) -> object: + def get_alarm_motion(self) -> Union[bool, Dict]: """ Gets the device alarm motion See examples/response/GetAlarmMotion.json for example response data. diff --git a/reolink_api/device.py b/reolink_api/device.py index 68f8179..8aba9ab 100644 --- a/reolink_api/device.py +++ b/reolink_api/device.py @@ -1,11 +1,16 @@ -from typing import List +from typing import ( + Dict, + List, + Union +) +from loguru import logger class DeviceAPIMixin: """API calls for getting device information.""" DEFAULT_HDD_ID = [0] - def get_hdd_info(self) -> object: + def get_hdd_info(self) -> Union[bool, Dict]: """ Gets all HDD and SD card information from Camera See examples/response/GetHddInfo.json for example response data. @@ -26,5 +31,5 @@ def format_hdd(self, hdd_id: List[int] = None) -> bool: r_data = self._execute_command('Format', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not format HDD/SD. Camera responded with:", r_data["value"]) + logger.warning(f"Could not format HDD/SD. Camera responded with: {r_data.get('value')}") return False diff --git a/reolink_api/display.py b/reolink_api/display.py index bf2b4ae..5e03987 100644 --- a/reolink_api/display.py +++ b/reolink_api/display.py @@ -1,7 +1,14 @@ +from typing import ( + Dict, + List +) +from loguru import logger + + class DisplayAPIMixin: """API calls related to the current image (osd, on screen display).""" - def get_osd(self) -> object: + def get_osd(self) -> List[Dict]: """ Get OSD information. See examples/response/GetOsd.json for example response data. @@ -10,7 +17,7 @@ def get_osd(self) -> object: body = [{"cmd": "GetOsd", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetOsd', body) - def get_mask(self) -> object: + def get_mask(self) -> List[Dict]: """ Get the camera mask information. See examples/response/GetMask.json for example response data. @@ -19,8 +26,8 @@ def get_mask(self) -> object: body = [{"cmd": "GetMask", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetMask', body) - def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, osd_channel_name: str = "", - osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0, + def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: bool = 0, + osd_channel_name: str = "", osd_channel_pos: str = "Lower Right", osd_time_enabled: bool = 0, osd_time_pos: str = "Lower Right") -> bool: """ Set OSD @@ -28,9 +35,11 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo :param channel: int channel id :param osd_channel_enabled: bool :param osd_channel_name: string channel name - :param osd_channel_pos: string channel position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + :param osd_channel_pos: string channel position + ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] :param osd_time_enabled: bool - :param osd_time_pos: string time position ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] + :param osd_time_pos: string time position + ["Upper Left","Top Center","Upper Right","Lower Left","Bottom Center","Lower Right"] :return: whether the action was successful """ body = [{"cmd": "SetOsd", "action": 1, "param": { @@ -43,5 +52,5 @@ def set_osd(self, bg_color: bool = 0, channel: int = 0, osd_channel_enabled: boo r_data = self._execute_command('SetOsd', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not set OSD. Camera responded with status:", r_data["value"]) + logger.warning(f"Could not set OSD. Camera responded with status: {r_data.get('value')}") return False diff --git a/reolink_api/image.py b/reolink_api/image.py index 0fbb952..b694060 100644 --- a/reolink_api/image.py +++ b/reolink_api/image.py @@ -1,3 +1,8 @@ +from typing import ( + Dict, + List +) + class ImageAPIMixin: """API calls for image settings.""" @@ -18,7 +23,7 @@ def set_adv_image_settings(self, drc: int = 128, rotation: int = 0, mirroring: int = 0, - nr3d: int = 1) -> object: + nr3d: int = 1) -> List[Dict]: """ Sets the advanced camera settings. @@ -70,7 +75,7 @@ def set_image_settings(self, contrast: int = 62, hue: int = 1, saturation: int = 125, - sharpness: int = 128) -> object: + sharpness: int = 128) -> List[Dict]: """ Sets the camera image settings. diff --git a/reolink_api/motion.py b/reolink_api/motion.py index 63a8ecf..4c8101c 100644 --- a/reolink_api/motion.py +++ b/reolink_api/motion.py @@ -1,15 +1,19 @@ -from typing import Union, List, Dict -from datetime import datetime as dt +from typing import ( + Union, + List, + Dict +) +from datetime import datetime # Type hints for input and output of the motion api response RAW_MOTION_LIST_TYPE = List[Dict[str, Union[str, int, Dict[str, str]]]] -PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, dt]]] +PROCESSED_MOTION_LIST_TYPE = List[Dict[str, Union[str, datetime]]] class MotionAPIMixin: """API calls for past motion alerts.""" - def get_motion_files(self, start: dt, end: dt = dt.now(), + def get_motion_files(self, start: datetime, end: datetime = datetime.now(), streamtype: str = 'sub') -> PROCESSED_MOTION_LIST_TYPE: """ Get the timestamps and filenames of motion detection events for the time range provided. @@ -70,7 +74,7 @@ def _process_motion_files(motion_files: RAW_MOTION_LIST_TYPE) -> PROCESSED_MOTIO for k, v in replace_fields.items(): if k in raw.keys(): raw[v] = raw.pop(k) - time_range[x.lower()] = dt(**raw) + time_range[x.lower()] = datetime(**raw) start, end = time_range.values() processed_motions.append({ 'start': start, diff --git a/reolink_api/network.py b/reolink_api/network.py index 54bbe2d..7b9645f 100644 --- a/reolink_api/network.py +++ b/reolink_api/network.py @@ -1,3 +1,10 @@ +from typing import ( + Dict, + List +) +from loguru import logger + + class NetworkAPIMixin: """API calls for network settings.""" def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: int = 9000, @@ -22,10 +29,10 @@ def set_net_port(self, http_port: int = 80, https_port: int = 443, media_port: i "rtspPort": rtsp_port }}}] self._execute_command('SetNetPort', body, multi=True) - print("Successfully Set Network Ports") + logger.info("Successfully Set Network Ports") return True - def set_wifi(self, ssid: str, password: str) -> object: + def set_wifi(self, ssid: str, password: str) -> List[Dict]: body = [{"cmd": "SetWifi", "action": 0, "param": { "Wifi": { "ssid": ssid, @@ -33,7 +40,7 @@ def set_wifi(self, ssid: str, password: str) -> object: }}}] return self._execute_command('SetWifi', body) - def get_net_ports(self) -> object: + def get_net_ports(self) -> List[Dict]: """ Get network ports See examples/response/GetNetworkAdvanced.json for example response data. @@ -44,15 +51,15 @@ def get_net_ports(self) -> object: {"cmd": "GetP2p", "action": 0, "param": {}}] return self._execute_command('GetNetPort', body, multi=True) - def get_wifi(self): + def get_wifi(self) -> List[Dict]: body = [{"cmd": "GetWifi", "action": 1, "param": {}}] return self._execute_command('GetWifi', body) - def scan_wifi(self): + def scan_wifi(self) -> List[Dict]: body = [{"cmd": "ScanWifi", "action": 1, "param": {}}] return self._execute_command('ScanWifi', body) - def get_network_general(self) -> object: + def get_network_general(self) -> List[Dict]: """ Get the camera information See examples/response/GetNetworkGeneral.json for example response data. @@ -61,7 +68,7 @@ def get_network_general(self) -> object: body = [{"cmd": "GetLocalLink", "action": 0, "param": {}}] return self._execute_command('GetLocalLink', body) - def get_network_ddns(self) -> object: + def get_network_ddns(self) -> List[Dict]: """ Get the camera DDNS network information See examples/response/GetNetworkDDNS.json for example response data. @@ -70,7 +77,7 @@ def get_network_ddns(self) -> object: body = [{"cmd": "GetDdns", "action": 0, "param": {}}] return self._execute_command('GetDdns', body) - def get_network_ntp(self) -> object: + def get_network_ntp(self) -> List[Dict]: """ Get the camera NTP network information See examples/response/GetNetworkNTP.json for example response data. @@ -79,7 +86,7 @@ def get_network_ntp(self) -> object: body = [{"cmd": "GetNtp", "action": 0, "param": {}}] return self._execute_command('GetNtp', body) - def get_network_email(self) -> object: + def get_network_email(self) -> List[Dict]: """ Get the camera email network information See examples/response/GetNetworkEmail.json for example response data. @@ -88,7 +95,7 @@ def get_network_email(self) -> object: body = [{"cmd": "GetEmail", "action": 0, "param": {}}] return self._execute_command('GetEmail', body) - def get_network_ftp(self) -> object: + def get_network_ftp(self) -> List[Dict]: """ Get the camera FTP network information See examples/response/GetNetworkFtp.json for example response data. @@ -97,7 +104,7 @@ def get_network_ftp(self) -> object: body = [{"cmd": "GetFtp", "action": 0, "param": {}}] return self._execute_command('GetFtp', body) - def get_network_push(self) -> object: + def get_network_push(self) -> List[Dict]: """ Get the camera push network information See examples/response/GetNetworkPush.json for example response data. @@ -106,7 +113,7 @@ def get_network_push(self) -> object: body = [{"cmd": "GetPush", "action": 0, "param": {}}] return self._execute_command('GetPush', body) - def get_network_status(self) -> object: + def get_network_status(self) -> List[Dict]: """ Get the camera status network information See examples/response/GetNetworkGeneral.json for example response data. diff --git a/reolink_api/ptz.py b/reolink_api/ptz.py index 463624e..744bfbc 100644 --- a/reolink_api/ptz.py +++ b/reolink_api/ptz.py @@ -1,46 +1,61 @@ +from typing import ( + Dict, + List +) + + class PtzAPIMixin: """ API for PTZ functions. """ - def _send_operation(self, operation, speed, index=None): - if index is None: - data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}] - else: - data = [{"cmd": "PtzCtrl", "action": 0, "param": { - "channel": 0, "op": operation, "speed": speed, "id": index}}] + def _send_operation(self, operation: str, speed: int, index=None) -> List[Dict]: + param_dict = { + 'channel': 0, + 'op': operation, + 'speed': speed + } + if index is not None: + param_dict['id'] = index + data = [ + { + 'cmd': 'PtzCtrl', + 'action': 0, + 'param': param_dict + } + ] return self._execute_command('PtzCtrl', data) - def _send_noparm_operation(self, operation): + def _send_noparam_operation(self, operation) -> List[Dict]: data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation}}] return self._execute_command('PtzCtrl', data) - def _send_set_preset(self, operation, enable, preset=1, name='pos1'): + def _send_set_preset(self, operation, enable, preset=1, name='pos1') -> List[Dict]: data = [{"cmd": "SetPtzPreset", "action": 0, "param": { "channel": 0, "enable": enable, "id": preset, "name": name}}] return self._execute_command('PtzCtrl', data) - def go_to_preset(self, speed=60, index=1): + def go_to_preset(self, speed=60, index=1) -> List[Dict]: """ Move the camera to a preset location :return: response json """ return self._send_operation('ToPos', speed=speed, index=index) - def add_preset(self, preset=1, name='pos1'): + def add_preset(self, preset=1, name='pos1') -> List[Dict]: """ Adds the current camera position to the specified preset. :return: response json """ return self._send_set_preset('PtzPreset', enable=1, preset=preset, name=name) - def remove_preset(self, preset=1, name='pos1'): + def remove_preset(self, preset=1, name='pos1') -> List[Dict]: """ Removes the specified preset :return: response json """ return self._send_set_preset('PtzPreset', enable=0, preset=preset, name=name) - def move_right(self, speed=25): + def move_right(self, speed=25) -> List[Dict]: """ Move the camera to the right The camera moves self.stop_ptz() is called. @@ -48,7 +63,7 @@ def move_right(self, speed=25): """ return self._send_operation('Right', speed=speed) - def move_right_up(self, speed=25): + def move_right_up(self, speed=25) -> List[Dict]: """ Move the camera to the right and up The camera moves self.stop_ptz() is called. @@ -56,7 +71,7 @@ def move_right_up(self, speed=25): """ return self._send_operation('RightUp', speed=speed) - def move_right_down(self, speed=25): + def move_right_down(self, speed=25) -> List[Dict]: """ Move the camera to the right and down The camera moves self.stop_ptz() is called. @@ -64,7 +79,7 @@ def move_right_down(self, speed=25): """ return self._send_operation('RightDown', speed=speed) - def move_left(self, speed=25): + def move_left(self, speed=25) -> List[Dict]: """ Move the camera to the left The camera moves self.stop_ptz() is called. @@ -72,7 +87,7 @@ def move_left(self, speed=25): """ return self._send_operation('Left', speed=speed) - def move_left_up(self, speed=25): + def move_left_up(self, speed=25) -> List[Dict]: """ Move the camera to the left and up The camera moves self.stop_ptz() is called. @@ -80,7 +95,7 @@ def move_left_up(self, speed=25): """ return self._send_operation('LeftUp', speed=speed) - def move_left_down(self, speed=25): + def move_left_down(self, speed=25) -> List[Dict]: """ Move the camera to the left and down The camera moves self.stop_ptz() is called. @@ -88,7 +103,7 @@ def move_left_down(self, speed=25): """ return self._send_operation('LeftDown', speed=speed) - def move_up(self, speed=25): + def move_up(self, speed=25) -> List[Dict]: """ Move the camera up. The camera moves self.stop_ptz() is called. @@ -96,7 +111,7 @@ def move_up(self, speed=25): """ return self._send_operation('Up', speed=speed) - def move_down(self, speed=25): + def move_down(self, speed=25) -> List[Dict]: """ Move the camera down. The camera moves self.stop_ptz() is called. @@ -104,14 +119,14 @@ def move_down(self, speed=25): """ return self._send_operation('Down', speed=speed) - def stop_ptz(self): + def stop_ptz(self) -> List[Dict]: """ Stops the cameras current action. :return: response json """ - return self._send_noparm_operation('Stop') + return self._send_noparam_operation('Stop') - def auto_movement(self, speed=25): + def auto_movement(self, speed=25) -> List[Dict]: """ Move the camera in a clockwise rotation. The camera moves self.stop_ptz() is called. diff --git a/reolink_api/recording.py b/reolink_api/recording.py index 195cce7..ab51f3c 100644 --- a/reolink_api/recording.py +++ b/reolink_api/recording.py @@ -1,8 +1,14 @@ -import requests import random import string from urllib import parse from io import BytesIO +from typing import ( + Callable, + Dict, + List +) +import requests +from loguru import logger from PIL import Image from reolink_api.RtspClient import RtspClient @@ -10,7 +16,7 @@ class RecordingAPIMixin: """API calls for recording/streaming image or video.""" - def get_recording_encoding(self) -> object: + def get_recording_encoding(self) -> List[Dict]: """ Get the current camera encoding settings for "Clear" and "Fluent" profiles. See examples/response/GetEnc.json for example response data. @@ -19,7 +25,7 @@ def get_recording_encoding(self) -> object: body = [{"cmd": "GetEnc", "action": 1, "param": {"channel": 0}}] return self._execute_command('GetEnc', body) - def get_recording_advanced(self) -> object: + def get_recording_advanced(self) -> List[Dict]: """ Get recording advanced setup data See examples/response/GetRec.json for example response data. @@ -29,15 +35,15 @@ def get_recording_advanced(self) -> object: return self._execute_command('GetRec', body) def set_recording_encoding(self, - audio=0, - main_bit_rate=8192, - main_frame_rate=8, - main_profile='High', - main_size="2560*1440", - sub_bit_rate=160, - sub_frame_rate=7, - sub_profile='High', - sub_size='640*480') -> object: + audio: int = 0, + main_bit_rate: int = 8192, + main_frame_rate: int = 8, + main_profile: str = 'High', + main_size: str = "2560*1440", + sub_bit_rate: int = 160, + sub_frame_rate: int = 7, + sub_profile: int = 'High', + sub_size: str = '640*480') -> List[Dict]: """ Sets the current camera encoding settings for "Clear" and "Fluent" profiles. :param audio: int Audio on or off @@ -51,29 +57,36 @@ def set_recording_encoding(self, :param sub_size: string Fluent Size :return: response """ - body = [{"cmd": "SetEnc", - "action": 0, - "param": - {"Enc": - {"audio": audio, - "channel": 0, - "mainStream": { - "bitRate": main_bit_rate, - "frameRate": main_frame_rate, - "profile": main_profile, - "size": main_size}, - "subStream": { - "bitRate": sub_bit_rate, - "frameRate": sub_frame_rate, - "profile": sub_profile, - "size": sub_size}} - }}] + body = [ + { + "cmd": "SetEnc", + "action": 0, + "param": { + "Enc": { + "audio": audio, + "channel": 0, + "mainStream": { + "bitRate": main_bit_rate, + "frameRate": main_frame_rate, + "profile": main_profile, + "size": main_size + }, + "subStream": { + "bitRate": sub_bit_rate, + "frameRate": sub_frame_rate, + "profile": sub_profile, + "size": sub_size + } + } + } + } + ] return self._execute_command('SetEnc', body) ########### # RTSP Stream ########### - def open_video_stream(self, callback=None, profile: str = "main", proxies=None): + def open_video_stream(self, callback: Callable = None, profile: str = "main", proxies: Dict = None): """ 'https://support.reolink.com/hc/en-us/articles/360007010473-How-to-Live-View-Reolink-Cameras-via-VLC-Media-Player' Blocking function creates a generator and returns the frames as it is spawned @@ -91,21 +104,22 @@ def get_snap(self, timeout: int = 3, proxies=None) -> Image or None: :param proxies: http/https proxies to pass to the request object. :return: Image or None """ - data = {} - data['cmd'] = 'Snap' - data['channel'] = 0 - data['rs'] = ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)) - data['user'] = self.username - data['password'] = self.password - parms = parse.urlencode(data).encode("utf-8") + data = { + 'cmd': 'Snap', + 'channel': 0, + 'rs': ''.join(random.choices(string.ascii_uppercase + string.digits, k=10)), + 'user': self.username, + 'password': self.password, + } + params = parse.urlencode(data).encode("utf-8") try: - response = requests.get(self.url, proxies=proxies, params=parms, timeout=timeout) + response = requests.get(self.url, proxies=proxies, params=params, timeout=timeout) if response.status_code == 200: return Image.open(BytesIO(response.content)) - print("Could not retrieve data from camera successfully. Status:", response.stats_code) + logger.warning("Could not retrieve data from camera successfully. Status:", response.status_code) return None except Exception as e: - print("Could not get Image data\n", e) + logger.error("Could not get Image data\n", e) raise diff --git a/reolink_api/resthandle.py b/reolink_api/resthandle.py index ac3fad9..59e1840 100644 --- a/reolink_api/resthandle.py +++ b/reolink_api/resthandle.py @@ -1,12 +1,17 @@ -import json +from typing import ( + Dict, + List, + Optional +) import requests +from loguru import logger class Request: proxies = None @staticmethod - def post(url: str, data, params=None) -> requests.Response or None: + def post(url: str, data: List[Dict], params=None) -> Optional[requests.Response]: """ Post request :param params: @@ -27,11 +32,11 @@ def post(url: str, data, params=None) -> requests.Response or None: else: raise ValueError(f"Http Request had non-200 Status: {r.status_code}", r.status_code) except Exception as e: - print("Post Error\n", e) + logger.error("Post Error\n", e) raise @staticmethod - def get(url, params, timeout=1) -> json or None: + def get(url: str, params: Dict, timeout: int = 1) -> Optional[requests.Response]: """ Get request :param url: @@ -41,8 +46,7 @@ def get(url, params, timeout=1) -> json or None: """ try: data = requests.get(url=url, verify=False, params=params, timeout=timeout, proxies=Request.proxies) - return data except Exception as e: - print("Get Error\n", e) + logger.error("Get Error\n", e) raise diff --git a/reolink_api/system.py b/reolink_api/system.py index 0eadc6a..febbf64 100644 --- a/reolink_api/system.py +++ b/reolink_api/system.py @@ -1,11 +1,17 @@ +from typing import ( + Dict, + List +) + + class SystemAPIMixin: """API for accessing general system information of the camera.""" - def get_general_system(self) -> object: + def get_general_system(self) -> List[Dict]: """:return: response json""" body = [{"cmd": "GetTime", "action": 1, "param": {}}, {"cmd": "GetNorm", "action": 1, "param": {}}] return self._execute_command('get_general_system', body, multi=True) - def get_performance(self) -> object: + def get_performance(self) -> List[Dict]: """ Get a snapshot of the current performance of the camera. See examples/response/GetPerformance.json for example response data. @@ -14,7 +20,7 @@ def get_performance(self) -> object: body = [{"cmd": "GetPerformance", "action": 0, "param": {}}] return self._execute_command('GetPerformance', body) - def get_information(self) -> object: + def get_information(self) -> List[Dict]: """ Get the camera information See examples/response/GetDevInfo.json for example response data. @@ -23,7 +29,7 @@ def get_information(self) -> object: body = [{"cmd": "GetDevInfo", "action": 0, "param": {}}] return self._execute_command('GetDevInfo', body) - def reboot_camera(self) -> object: + def reboot_camera(self) -> List[Dict]: """ Reboots the camera :return: response json @@ -31,11 +37,11 @@ def reboot_camera(self) -> object: body = [{"cmd": "Reboot", "action": 0, "param": {}}] return self._execute_command('Reboot', body) - def get_dst(self) -> object: + def get_dst(self) -> List[Dict]: """ Get the camera DST information See examples/response/GetDSTInfo.json for example response data. :return: response json """ body = [{"cmd": "GetTime", "action": 0, "param": {}}] - return self._execute_command('GetTime', body) \ No newline at end of file + return self._execute_command('GetTime', body) diff --git a/reolink_api/user.py b/reolink_api/user.py index 9d430f6..017a7b1 100644 --- a/reolink_api/user.py +++ b/reolink_api/user.py @@ -1,6 +1,13 @@ +from typing import ( + Dict, + List +) +from loguru import logger + + class UserAPIMixin: """User-related API calls.""" - def get_online_user(self) -> object: + def get_online_user(self) -> List[Dict]: """ Return a list of current logged-in users in json format See examples/response/GetOnline.json for example response data. @@ -9,7 +16,7 @@ def get_online_user(self) -> object: body = [{"cmd": "GetOnline", "action": 1, "param": {}}] return self._execute_command('GetOnline', body) - def get_users(self) -> object: + def get_users(self) -> List[Dict]: """ Return a list of user accounts from the camera in json format. See examples/response/GetUser.json for example response data. @@ -31,7 +38,7 @@ def add_user(self, username: str, password: str, level: str = "guest") -> bool: r_data = self._execute_command('AddUser', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not add user. Camera responded with:", r_data["value"]) + logger.warning(f"Could not add user. Camera responded with: {r_data.get('value')}") return False def modify_user(self, username: str, password: str) -> bool: @@ -45,7 +52,7 @@ def modify_user(self, username: str, password: str) -> bool: r_data = self._execute_command('ModifyUser', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not modify user:", username, "\nCamera responded with:", r_data["value"]) + logger.warning(f"Could not modify user: {username}\nCamera responded with: {r_data.get('value')}") return False def delete_user(self, username: str) -> bool: @@ -58,5 +65,5 @@ def delete_user(self, username: str) -> bool: r_data = self._execute_command('DelUser', body)[0] if r_data["value"]["rspCode"] == 200: return True - print("Could not delete user:", username, "\nCamera responded with:", r_data["value"]) + logger.warning(f"Could not delete user: {username}\nCamera responded with: {r_data.get('value')}") return False diff --git a/reolink_api/util.py b/reolink_api/util.py index c824002..a09892a 100644 --- a/reolink_api/util.py +++ b/reolink_api/util.py @@ -1,11 +1,12 @@ +from typing import Callable from threading import Thread -def threaded(fn): - def wrapper(*args, **kwargs): +def threaded(fn) -> Callable: + def wrapper(*args, **kwargs) -> Thread: thread = Thread(target=fn, args=args, kwargs=kwargs) thread.daemon = True thread.start() return thread - return wrapper \ No newline at end of file + return wrapper diff --git a/reolink_api/zoom.py b/reolink_api/zoom.py index 2bf0021..8889620 100644 --- a/reolink_api/zoom.py +++ b/reolink_api/zoom.py @@ -1,54 +1,60 @@ +from typing import ( + Dict, + List +) + + class ZoomAPIMixin: """ API for zooming and changing focus. Note that the API does not allow zooming/focusing by absolute values rather that changing focus/zoom for a given time. """ - def _start_operation(self, operation, speed): + def _start_operation(self, operation, speed) -> List[Dict]: data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": operation, "speed": speed}}] return self._execute_command('PtzCtrl', data) - def _stop_zooming_or_focusing(self): + def _stop_zooming_or_focusing(self) -> List[Dict]: """This command stops any ongoing zooming or focusing actions.""" data = [{"cmd": "PtzCtrl", "action": 0, "param": {"channel": 0, "op": "Stop"}}] return self._execute_command('PtzCtrl', data) - def start_zooming_in(self, speed=60): + def start_zooming_in(self, speed=60) -> List[Dict]: """ The camera zooms in until self.stop_zooming() is called. :return: response json """ return self._start_operation('ZoomInc', speed=speed) - def start_zooming_out(self, speed=60): + def start_zooming_out(self, speed=60) -> List[Dict]: """ The camera zooms out until self.stop_zooming() is called. :return: response json """ return self._start_operation('ZoomDec', speed=speed) - def stop_zooming(self): + def stop_zooming(self) -> List[Dict]: """ Stop zooming. :return: response json """ return self._stop_zooming_or_focusing() - def start_focusing_in(self, speed=32): + def start_focusing_in(self, speed=32) -> List[Dict]: """ The camera focuses in until self.stop_focusing() is called. :return: response json """ return self._start_operation('FocusInc', speed=speed) - def start_focusing_out(self, speed=32): + def start_focusing_out(self, speed=32) -> List[Dict]: """ The camera focuses out until self.stop_focusing() is called. :return: response json """ return self._start_operation('FocusDec', speed=speed) - def stop_focusing(self): + def stop_focusing(self) -> List[Dict]: """ Stop focusing. :return: response json diff --git a/setup.py b/setup.py index e89fd5b..a41db3f 100644 --- a/setup.py +++ b/setup.py @@ -1,57 +1,10 @@ -#!/usr/bin/python3 -import os -import re -import codecs -from setuptools import setup, find_packages - - -def read(*parts): - with codecs.open(os.path.join(here, *parts), 'r') as fp: - return fp.read() - - -def find_version(*file_paths): - version_file = read(*file_paths) - version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", version_file, re.M) - if version_match: - return version_match.group(1) - raise RuntimeError("Unable to find version string.") - - -here = os.path.abspath(os.path.dirname(__file__)) -# read the contents of your README file -with open(os.path.join(here, 'README.md'), encoding='utf-8') as f: - long_description = f.read() - - -# Package meta-data. -NAME = 'reolink_api' -DESCRIPTION = 'Reolink Camera API written in Python 3.6' -URL = 'https://github.com/Benehiko/ReolinkCameraAPI' -AUTHOR_EMAIL = '' -AUTHOR = 'Benehiko' -LICENSE = 'GPL-3.0' -INSTALL_REQUIRES = [ - 'numpy==1.19.4', - 'opencv-python==4.4.0.46', - 'Pillow==8.0.1', - 'PySocks==1.7.1', - 'PyYaml==5.3.1', - 'requests>=2.18.4', -] - - -setup( - name=NAME, - python_requires='>=3.6.0', - version=find_version('reolink_api', '__init__.py'), - description=DESCRIPTION, - long_description=long_description, - long_description_content_type='text/markdown', - author=AUTHOR, - author_email=AUTHOR_EMAIL, - url=URL, - license=LICENSE, - install_requires=INSTALL_REQUIRES, - packages=find_packages(exclude=['examples', 'tests']) -) +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" + So, pyproject.toml takes over the setup.py functions. For development work, we'll keep this, + but it's really not needed for anything other than making an editable install. +""" +import setuptools + +if __name__ == '__main__': + setuptools.setup() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/common.py b/tests/common.py new file mode 100644 index 0000000..e04c746 --- /dev/null +++ b/tests/common.py @@ -0,0 +1,30 @@ +import string +import random +from unittest.mock import patch + + +def make_patcher(obj, name: str) -> patch: + """Makes patching a bit easier + + Args: + obj: this is generally the test object you want the patch applied to. If this is the case, obj=self + name: the name of the library to be patched. Keep in mind, library paths also work + (e.g. requests.get) + """ + patcher = patch(name) + patched_item = patcher.start() + obj.addCleanup(patcher.stop) + return patched_item + + +def random_string(n_chars: int = 10, addl_chars: str = None) -> str: + """Generates a random string of n characters in length""" + chars = string.ascii_letters + if addl_chars is not None: + chars += addl_chars + return ''.join([random.choice(chars) for i in range(n_chars)]) + + +def random_float(min_rng: float, max_rng: float) -> float: + """Randomly selects a float based on a given range""" + return random.random() * (max_rng - min_rng) + min_rng diff --git a/tests/test_camera.py b/tests/test_camera.py index 2fa5cf2..81f30eb 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -1,39 +1,40 @@ -import os -from configparser import RawConfigParser import unittest from reolink_api import Camera - - -def read_config(props_path: str) -> dict: - """Reads in a properties file into variables. - - NB! this config file is kept out of commits with .gitignore. The structure of this file is such: - # secrets.cfg - [camera] - ip={ip_address} - username={username} - password={password} - """ - config = RawConfigParser() - assert os.path.exists(props_path), f"Path does not exist: {props_path}" - config.read(props_path) - return config +from tests.common import make_patcher class TestCamera(unittest.TestCase): @classmethod def setUpClass(cls) -> None: - cls.config = read_config('../secrets.cfg') + pass def setUp(self) -> None: - self.cam = Camera(self.config.get('camera', 'ip'), self.config.get('camera', 'username'), - self.config.get('camera', 'password')) + # Mock requests so these. tests don't touch external systems + self.mock_requests = make_patcher(self, 'reolink_api.resthandle.requests') + # Prime responses + self.mock_requests.post.return_value.status_code = 200 + self.mock_requests.post.return_value.json.return_value = [ + { + 'code': '0', + 'value': { + 'Token': { + 'name': 'somekindoftoken' + } + } + } + ] + + self.ip = '0.0.0.1' + self.un = 'someuser' + self.pw = 'somepw' + self.cam = Camera(ip=self.ip, username=self.un, password=self.pw) def test_camera(self): """Test that camera connects and gets a token""" - self.assertTrue(self.cam.ip == self.config.get('camera', 'ip')) + self.assertTrue(self.cam.ip == self.ip) self.assertTrue(self.cam.token != '') + self.mock_requests.post.assert_called() if __name__ == '__main__': diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..c840a0f --- /dev/null +++ b/tox.ini @@ -0,0 +1,55 @@ +[tox] +envlist = + py310 + flake8 +isolated_build = true +skipsdist = true + +[testenv] +whitelist_externals = poetry +commands = + poetry install -v -E test + poetry run pytest --pyargs +extras = + dev + test + +[testenv:flake8] +skip_install = true +deps = + flake8 +commands = + poetry run flake8 reolink_api/ + +[coverage:report] +show_missing = true +skip_empty = true +skip_covered = true +precision = 2 +fail_under = 30.00 +exclude_lines = + pragma: no cover + def __repr__ + if __name__ == ['"]__main__['"]: + if TYPE_CHECKING: + +[coverage:run] +omit = + */__init__.py + tests/* +source = reolink_api +branch = true + +[pytest] +testpaths = tests/ +addopts = + --cov + --cov-config=tox.ini + --cov-report=term + --cov-report=html + --disable-pytest-warnings + +[flake8] +extend-ignore = E501, W291 +exclude = + */__init__.py \ No newline at end of file