From 7f3c210b54dcd418128a489f8417432c9d6dc997 Mon Sep 17 00:00:00 2001 From: Robbert Verbruggen Date: Wed, 3 Jan 2024 10:50:34 +0100 Subject: [PATCH] V1.1.0 (#60) * Set the next dev version number: 1.0.4-dev * Include python 3.9 in tests * Use a static python version when linting No need for matrix support over all python version when linting. * Fix lint.yml indent * Update index.rst * Include codeql-analysis * style: fixing some code smells * Update tox.ini * Update test.yml * add valve support * fix program string * roll back to what worked try readd python 3.6 Revert "update formatting" This reverts commit 5b4ec911032d96a18590c60d2b75c6b444ad9fdb. update formatting * remove python 3.6 * try to make black happy * build: update supported python versions * build: quote python test versions * chore: readd removed blank line * docs: add all-contributors config * docs: add all-contributors config * docs: update README.md [skip ci] * docs: create .all-contributorsrc [skip ci] * update tests * Update publish.yml * Update publish_test.yml * Update publish_test.yml * Update setup.cfg * Update setup.py * Delete setup.cfg * Update setup.py * Update publish.yml * Update setup.py with new version number * Update setup.py version number --------- Co-authored-by: Brian Rogers Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com> --- .all-contributorsrc | 28 ++++ .all-contributorsrc.json | 4 + .github/workflows/codeql-analysis.yml | 71 ++++++++++ .github/workflows/lint.yml | 10 +- .github/workflows/publish.yml | 22 ++-- .github/workflows/publish_test.yml | 20 +-- .github/workflows/test.yml | 4 +- README.md | 36 ++++- docs/index.rst | 2 +- rachiopy/__init__.py | 7 + rachiopy/program.py | 86 ++++++++++++ rachiopy/rachioobject.py | 74 ++++++++++- rachiopy/summary.py | 34 +++++ rachiopy/valve.py | 140 ++++++++++++++++++++ setup.cfg | 2 - setup.py | 12 +- tests/constants.py | 1 + tests/test_device.py | 32 ++--- tests/test_flexschedulerule.py | 2 +- tests/test_notification.py | 6 +- tests/test_program.py | 100 ++++++++++++++ tests/test_rachio.py | 3 + tests/test_schedulerule.py | 2 +- tests/test_summary.py | 47 +++++++ tests/test_valve.py | 181 ++++++++++++++++++++++++++ tests/test_zone.py | 2 +- tox.ini | 2 +- 27 files changed, 871 insertions(+), 59 deletions(-) create mode 100644 .all-contributorsrc create mode 100644 .all-contributorsrc.json create mode 100644 .github/workflows/codeql-analysis.yml create mode 100644 rachiopy/program.py create mode 100644 rachiopy/summary.py create mode 100644 rachiopy/valve.py delete mode 100644 setup.cfg create mode 100644 tests/test_program.py create mode 100644 tests/test_summary.py create mode 100644 tests/test_valve.py diff --git a/.all-contributorsrc b/.all-contributorsrc new file mode 100644 index 0000000..58ca321 --- /dev/null +++ b/.all-contributorsrc @@ -0,0 +1,28 @@ +{ + "files": [ + "README.md" + ], + "imageSize": 100, + "commit": false, + "commitType": "docs", + "commitConvention": "angular", + "contributors": [ + { + "login": "brg468", + "name": "Brian Rogers", + "avatar_url": "https://avatars.githubusercontent.com/u/19143191?v=4", + "profile": "https://github.com/brg468", + "contributions": [ + "code", + "doc", + "test" + ] + } + ], + "contributorsPerLine": 7, + "skipCi": true, + "repoType": "github", + "repoHost": "https://github.com", + "projectName": "rachiopy", + "projectOwner": "rfverbruggen" +} diff --git a/.all-contributorsrc.json b/.all-contributorsrc.json new file mode 100644 index 0000000..f382d3f --- /dev/null +++ b/.all-contributorsrc.json @@ -0,0 +1,4 @@ +{ + "projectName": "rachiopy", + "projectOwner": "rfverbruggen" +} diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml new file mode 100644 index 0000000..72c6089 --- /dev/null +++ b/.github/workflows/codeql-analysis.yml @@ -0,0 +1,71 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ dev, master ] + pull_request: + # The branches below must be a subset of the branches above + branches: [ dev ] + schedule: + - cron: '28 6 * * 6' + +jobs: + analyze: + name: Analyze + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + security-events: write + + strategy: + fail-fast: false + matrix: + language: [ 'python' ] + # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] + # Learn more: + # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v1 + with: + languages: ${{ matrix.language }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + # queries: ./path/to/local/query, your-org/your-repo/queries@main + + # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@v1 + + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 https://git.io/JvXDl + + # ✏ī¸ If the Autobuild fails above, remove it and uncomment the following three lines + # and modify them (or add more) to build your code if your project + # uses a compiled language + + #- run: | + # make bootstrap + # make release + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v1 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 002dfd5..d17fa56 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -6,18 +6,14 @@ jobs: # The type of runner that the job will run on runs-on: ubuntu-latest - strategy: - matrix: - python-version: [3.6, 3.7, 3.8] - # Steps represent a sequence of tasks that will be executed as part of the job steps: # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} + - name: Set up Python 3.x uses: actions/setup-python@v2 with: - python-version: ${{ matrix.python-version }} + python-version: 3.x - name: Install dependencies run: | python -m pip install --upgrade pip @@ -28,4 +24,4 @@ jobs: - name: Run flake8 run: flake8 - name: Run pydocstyle - run: pydocstyle {posargs:rachiopy tests} \ No newline at end of file + run: pydocstyle {posargs:rachiopy tests} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5dd5a33..6836e35 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -7,13 +7,18 @@ on: jobs: build-n-publish: name: Build and publish Python distributions to PyPI - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/project/RachioPy + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@master - - name: Set up Python 3.8 - uses: actions/setup-python@v1 + - name: Set up Python 3.x + uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.x - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel @@ -25,12 +30,9 @@ jobs: run: python setup.py sdist bdist_wheel - name: Publish distribution to Test PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master + uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ + repository-url: https://test.pypi.org/legacy/ - name: Publish distribution to PyPI if: startsWith(github.ref, 'refs/tags') - uses: pypa/gh-action-pypi-publish@master - with: - password: ${{ secrets.pypi_password }} + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.github/workflows/publish_test.yml b/.github/workflows/publish_test.yml index 5cb070a..f3275c7 100644 --- a/.github/workflows/publish_test.yml +++ b/.github/workflows/publish_test.yml @@ -8,13 +8,18 @@ on: jobs: build-n-publish: name: Build and publish Python distributions to Test PyPI - runs-on: ubuntu-18.04 + runs-on: ubuntu-latest + environment: + name: pypi + url: https://test.pypi.org/project/RachioPy + permissions: + id-token: write # IMPORTANT: this permission is mandatory for trusted publishing steps: - uses: actions/checkout@master - - name: Set up Python 3.8 - uses: actions/setup-python@v1 + - name: Set up Python 3.x + uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.x - name: Install dependencies run: | python -m pip install --upgrade pip setuptools wheel @@ -24,8 +29,7 @@ jobs: run: python -m unittest discover -v tests - name: Build distribution run: python setup.py sdist bdist_wheel - - name: Publish distribution to Test PyPI - uses: pypa/gh-action-pypi-publish@master + - name: Publish package distributions to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.test_pypi_password }} - repository_url: https://test.pypi.org/legacy/ \ No newline at end of file + repository-url: https://test.pypi.org/legacy/ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9961237..bd440c5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,7 @@ jobs: strategy: matrix: - python-version: [3.6, 3.7, 3.8] + python-version: ['3.8', '3.9', '3.10', '3.11', '3.12'] # Steps represent a sequence of tasks that will be executed as part of the job steps: @@ -24,4 +24,4 @@ jobs: if [ -f requirements.txt ]; then pip install -r requirements.txt; fi if [ -f requirements_test.txt ]; then pip install -r requirements_test.txt; fi - name: Run unittests - run: python -m unittest discover -v tests \ No newline at end of file + run: python -m unittest discover -v tests diff --git a/README.md b/README.md index ddcf089..e68e8e6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,12 @@ -Rachiopy -======== +# Rachiopy + +[![All Contributors](https://img.shields.io/badge/all_contributors-1-orange.svg?style=flat-square)](#contributors-) + + This python package provides a interface to the Rachio public API. -Usage ------ +## Usage + ```python from rachiopy import Rachio @@ -13,3 +16,28 @@ r.person.info() ``` For the complete documentation visit [read the docs](https://rachiopy.readthedocs.io/en/latest/). + +## Contributors + + + + + + + + + + +
Brian Rogers
Brian Rogers

đŸ’ģ 📖 ⚠ī¸
+ + + + + + + + + + + + diff --git a/docs/index.rst b/docs/index.rst index bc1d1dd..ec30503 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,7 +19,7 @@ Getting Started from rachiopy import Rachio r = Rachio("8e600a4c-0027-4a9a-9bda-dc8d5c90350d") - resp, content = r.person.getInfo() + resp, content = r.person.info() print (resp["status"]) print (content["id"]) diff --git a/rachiopy/__init__.py b/rachiopy/__init__.py index e5fc760..21d0910 100644 --- a/rachiopy/__init__.py +++ b/rachiopy/__init__.py @@ -7,11 +7,15 @@ from rachiopy.notification import Notification from rachiopy.schedulerule import Schedulerule from rachiopy.zone import Zone +from rachiopy.valve import Valve +from rachiopy.summary import SummaryServce +from rachiopy.program import Program class Rachio(RachioObject): """Object representing the Rachio API.""" + # pylint: disable=too-many-instance-attributes def __init__(self, authtoken: str): """Initialze the Rachio API wrapper. @@ -25,3 +29,6 @@ def __init__(self, authtoken: str): self.notification = Notification(authtoken) self.schedulerule = Schedulerule(authtoken) self.zone = Zone(authtoken) + self.valve = Valve(authtoken) + self.summary = SummaryServce(authtoken) + self.program = Program(authtoken) diff --git a/rachiopy/program.py b/rachiopy/program.py new file mode 100644 index 0000000..6505195 --- /dev/null +++ b/rachiopy/program.py @@ -0,0 +1,86 @@ +"""Program module for the smart hose timer.""" + +from rachiopy.rachioobject import RachioObject + + +class Program(RachioObject): + """Program class for the smart hose timer.""" + + def list_programs(self, valve_id: str): + """Retreive the list of programs (schedules) for a valve. + + For more info of the content in the response see: + https://rachio.readme.io/docs/programservice_listprograms + + :param valve_id: Valve's unique id + :type valve_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body. + :rtype: tuple + """ + path = f"program/listPrograms/{valve_id}" + return self.valve_get_request(path) + + def get_program(self, program_id: str): + """Retreive the information for a specific program. + + For more info of the content in the response see: + https://rachio.readme.io/docs/programservice_getprogram + + :param program_id: Program's unique id + :type program_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + path = f"program/getProgram/{program_id}" + return self.valve_get_request(path) + + def create_skip_overrides(self, program_id: str, timestamp: str): + """Create manual skips for the specific program run time. + You can retrieve the runtimes from SummaryService.getValveDayViews + + For more info of the content in the response see: + https://rachio.readme.io/docs/programservice_createskipoverrides + + :param program_id: Program's unique id + :type program_id: str + + :param timestamp: Timestamp of the run to skip + :type timestamp: timestamp + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + payload = {"programId": program_id, "timestamp": timestamp} + return self.valve_post_request("program/createSkipOverrides", payload) + + def delete_skip_overrides(self, program_id: str, timestamp: str): + """Cancel program skips for the specified program run time. + You can retrieve upcoming skips from SummaryService.getValveDayViews + + For more info of the content in the response see: + https://rachio.readme.io/docs/programservice_deleteskipoverrides + + :param program_id: Program's unique id + :type program_id: str + + :param timestamp: Timestamp of the run skip to delete + :type timestamp: timestamp + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + payload = {"programId": program_id, "timestamp": timestamp} + return self.valve_post_request("program/deleteSkipOverrides", payload) diff --git a/rachiopy/rachioobject.py b/rachiopy/rachioobject.py index f519908..4eec922 100644 --- a/rachiopy/rachioobject.py +++ b/rachiopy/rachioobject.py @@ -4,6 +4,7 @@ from requests import Session _API_URL = "https://api.rach.io/1/public" +_VALVE_URL = "https://cloud-rest.rach.io" class RachioObject: @@ -32,7 +33,7 @@ def __init__(self, authtoken: str, http_session=None, timeout=25): self.timeout = timeout def _request(self, path: str, method: str, body=None): - """Make a request from the API. + """Make a request to the API. :return: The return value is a tuple of (response, content), the first being and instance of the httplib2.Response class, the second @@ -100,3 +101,74 @@ def delete_request(self, path: str, body=None): :rtype: tuple """ return self._request(path, "DELETE", body) + + def _valve_request(self, path: str, method: str, body=None): + """Make a request to the API. + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + + if body is not None: + body = json.dumps(body) + + url = f"{_VALVE_URL}/{path}" + response = self._http_session.request( + method, url, headers=self._headers, data=body, timeout=self.timeout + ) + + content_type = response.headers.get("content-type") + headers = {k.lower(): v for k, v in response.headers.items()} + headers["status"] = response.status_code + + if content_type and content_type.startswith("application/json"): + return headers, response.json() + + return headers, response.text + + def valve_get_request(self, path: str, body=None): + """Make a GET request to the valve API. + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + return self._valve_request(path, "GET", body) + + def valve_put_request(self, path: str, body=None): + """Make a PUT request to the valve API. + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + return self._valve_request(path, "PUT", body) + + def valve_post_request(self, path: str, body=None): + """Make a POST request to the valve API. + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + return self._valve_request(path, "POST", body) + + def valve_delete_request(self, path: str, body=None): + """Make a DELETE request to the valve API. + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + return self._valve_request(path, "DELETE", body) diff --git a/rachiopy/summary.py b/rachiopy/summary.py new file mode 100644 index 0000000..66805a6 --- /dev/null +++ b/rachiopy/summary.py @@ -0,0 +1,34 @@ +"""Smart Hose Timer scheudle summary calls.""" + +from rachiopy.rachioobject import RachioObject + + +class SummaryServce(RachioObject): + """Scheudle summary class.""" + + def get_valve_day_views(self, base_id: str, start, end): + """List historical and upcoming valve runs and skips. + + For more info of the content in the response see: + https://rachio.readme.io/docs/summaryservice_getvalvedayviews + + :param base_id: Base's unique id + :type dev_id: str + + :param start: Start date + :type start: Object[] + + :param end: End date + :type end: Object[] + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body. + :rtype: tuple + """ + payload = { + "resourceId": {"baseStationId": base_id}, + "start": start, + "end": end, + } + return self.valve_post_request("summary/getValveDayViews", payload) diff --git a/rachiopy/valve.py b/rachiopy/valve.py new file mode 100644 index 0000000..383999c --- /dev/null +++ b/rachiopy/valve.py @@ -0,0 +1,140 @@ +"""Valve Service.""" + +from rachiopy.rachioobject import RachioObject + + +class Valve(RachioObject): + """Valve class for smart hose timer.""" + + def get_base_station(self, base_id: str): + """Retreive the information for a specific base station. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_getbasestation + + :param base_id: Base station's unique id + :type user_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + path = f"valve/getBaseStation/{base_id}" + return self.valve_get_request(path) + + def get_valve(self, valve_id: str): + """Retrieve the information for a specific smart valve. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_getvalve + + :param valve_id: Valve's unique id + :type user_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + path = f"valve/getValve/{valve_id}" + return self.valve_get_request(path) + + def list_base_stations(self, user_id: str): + """Retrieve all base stations for a given user ID. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_listbasestations + + :param user_id: Person's unique id + :type user_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + path = f"valve/listBaseStations/{user_id}" + return self.valve_get_request(path) + + def list_valves(self, base_id: str): + """Retreive all valves on a given base station. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_listvalves + + :param base_id: Base station's unique id + :type user_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + path = f"valve/listValves/{base_id}" + return self.valve_get_request(path) + + def set_default_runtime(self, valve_id: str, duration: int): + """Set the runtime for a valve when the button is pressed. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_setdefaultruntime + + :param valve_id: Valve's unique id + :type user_id: str + + :param duration: Duration in seconds + :type duration: int + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + payload = {"valveId": valve_id, "defaultRuntimeSeconds": duration} + return self.valve_put_request("valve/setDefaultRuntime", payload) + + def start_watering(self, valve_id: str, duration: int): + """Start a valve. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_startwatering + + :param valve_id: Valve's unique id + :type user_id: str + + :param duration: Duration in seconds + :type duration: int + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + assert 0 <= duration <= 86400, "duration must be in range 0-86400" + payload = {"valveId": valve_id, "durationSeconds": duration} + return self.valve_put_request("valve/startWatering", payload) + + def stop_watering(self, valve_id: str): + """Stop a valve. + + For more info of the content in the response see: + https://rachio.readme.io/docs/valveservice_stopwatering + + :param valve_id: Valve's unique id + :type user_id: str + + :return: The return value is a tuple of (response, content), the first + being and instance of the httplib2.Response class, the second + being a string that contains the response entity body (Python + object if it contains JSON). + :rtype: tuple + """ + payload = {"valveId": valve_id} + return self.valve_put_request("valve/stopWatering", payload) diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index b88034e..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[metadata] -description-file = README.md diff --git a/setup.py b/setup.py index 6674fd2..b60ed23 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,11 @@ """Rachiopy setup script.""" from setuptools import find_packages, setup +from datetime import datetime +from pathlib import Path -VERSION = "1.0.3" +NOW = datetime.now().strftime("%m%d%Y%H%M%S") + +VERSION = "1.1.0" GITHUB_USERNAME = "rfverbruggen" GITHUB_REPOSITORY = "rachiopy" @@ -14,6 +18,10 @@ PACKAGES = find_packages(exclude=["tests", "tests.*"]) +# read the contents of your README file +this_directory = Path(__file__).parent +long_description = (this_directory / "README.md").read_text() + setup( name="RachioPy", version=VERSION, @@ -26,6 +34,8 @@ project_urls=PROJECT_URLS, license="MIT", description="A Python module for the Rachio API.", + long_description=long_description, + long_description_content_type='text/markdown', platforms="Cross Platform", classifiers=[ "Development Status :: 5 - Production/Stable", diff --git a/tests/constants.py b/tests/constants.py index eec3442..c7b2a9c 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -5,6 +5,7 @@ from requests import Response BASE_API_URL = "https://api.rach.io/1/public" +VALVE_API_URL = "https://cloud-rest.rach.io" AUTHTOKEN = "1c1d9f3d-39c9-42b1-abc0-066f5a05cdef" diff --git a/tests/test_device.py b/tests/test_device.py index 53205c9..5f7b072 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -33,7 +33,7 @@ def test_get(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/device/" f"{deviceid}", + args[1], f"{BASE_API_URL}/device/{deviceid}", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -49,9 +49,9 @@ def test_current_schedule(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/device/" f"{deviceid}/current_schedule", + args[1], f"{BASE_API_URL}/device/{deviceid}/current_schedule", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -69,7 +69,7 @@ def test_event(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/" @@ -91,9 +91,9 @@ def test_forecast(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/device/" f"{deviceid}/forecast?units=US", + args[1], f"{BASE_API_URL}/device/{deviceid}/forecast?units=US", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -102,9 +102,9 @@ def test_forecast(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/device/" f"{deviceid}/forecast?units=US", + args[1], f"{BASE_API_URL}/device/{deviceid}/forecast?units=US", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -113,10 +113,10 @@ def test_forecast(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], - f"{BASE_API_URL}/device/" f"{deviceid}/forecast?units=METRIC", + f"{BASE_API_URL}/device/{deviceid}/forecast?units=METRIC", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -135,7 +135,7 @@ def test_stop_water(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/stop_water", ) @@ -154,7 +154,7 @@ def test_rain_delay(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/rain_delay", ) @@ -180,7 +180,7 @@ def test_turn_on(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/on", ) @@ -198,7 +198,7 @@ def test_turn_off(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/off", ) @@ -217,7 +217,7 @@ def test_pause_zone_run(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/pause_zone_run", ) @@ -245,7 +245,7 @@ def test_resume_zone_run(self, mock): args, kwargs = mock.call_args - # Check that the mock funciton is called with the rights args. + # Check that the mock function is called with the rights args. self.assertEqual( args[1], f"{BASE_API_URL}/device/resume_zone_run", ) diff --git a/tests/test_flexschedulerule.py b/tests/test_flexschedulerule.py index ed1379e..71ec46f 100644 --- a/tests/test_flexschedulerule.py +++ b/tests/test_flexschedulerule.py @@ -32,7 +32,7 @@ def test_get(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( args[1], - f"{BASE_API_URL}/flexschedulerule/" f"{flexscheduleruleid}", + f"{BASE_API_URL}/flexschedulerule/{flexscheduleruleid}", ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) diff --git a/tests/test_notification.py b/tests/test_notification.py index d212aca..966d3f5 100644 --- a/tests/test_notification.py +++ b/tests/test_notification.py @@ -48,7 +48,7 @@ def test_get_device_webhook(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/notification/" f"{deviceid}/webhook" + args[1], f"{BASE_API_URL}/notification/{deviceid}/webhook" ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) @@ -124,7 +124,7 @@ def test_delete(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/notification/webhook/" f"{hookid}" + args[1], f"{BASE_API_URL}/notification/webhook/{hookid}" ) self.assertEqual(args[0], "DELETE") self.assertEqual(kwargs["data"], None) @@ -142,7 +142,7 @@ def test_get(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/notification/webhook/" f"{hookid}" + args[1], f"{BASE_API_URL}/notification/webhook/{hookid}" ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) diff --git a/tests/test_program.py b/tests/test_program.py new file mode 100644 index 0000000..185e79d --- /dev/null +++ b/tests/test_program.py @@ -0,0 +1,100 @@ +"""Program object test module""" + +import unittest +import uuid +import json +from unittest.mock import patch + +from rachiopy import Program +from tests.constants import VALVE_API_URL, AUTHTOKEN, RESPONSE200 + + +class TestProgramMethods(unittest.TestCase): + """Class containing the Program object tests.""" + + def setUp(self): + self.program = Program(AUTHTOKEN) + + def test_init(self): + """Test if the constructor works as expected.""" + self.assertEqual(self.program.authtoken, AUTHTOKEN) + + @patch("requests.Session.request") + def test_list_programs(self, mock): + """Test if the list programs method works as expected.""" + mock.return_value = RESPONSE200 + + valveid = str(uuid.uuid4()) + + self.program.list_programs(valveid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/program/listPrograms/{valveid}" + ) + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_get_program(self, mock): + """Test if the get program method works as expected.""" + mock.return_value = RESPONSE200 + + programid = str(uuid.uuid4()) + + self.program.get_program(programid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/program/getProgram/{programid}" + ) + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_create_skip_overrides(self, mock): + """Test if the create skip overrides method works as expected.""" + mock.return_value = RESPONSE200 + + programid = str(uuid.uuid4()) + timestamp = 1414818000000 + + self.program.create_skip_overrides(programid, timestamp) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/program/createSkipOverrides" + ) + self.assertEqual(args[0], "POST") + self.assertEqual( + kwargs["data"], + json.dumps({"programId": programid, "timestamp": timestamp}), + ) + + @patch("requests.Session.request") + def test_delete_skip_overrides(self, mock): + """Test if the delete skip overrides method works as expected.""" + mock.return_value = RESPONSE200 + + programid = str(uuid.uuid4()) + timestamp = 1414818000000 + + self.program.delete_skip_overrides(programid, timestamp) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/program/deleteSkipOverrides" + ) + self.assertEqual(args[0], "POST") + self.assertEqual( + kwargs["data"], + json.dumps({"programId": programid, "timestamp": timestamp}), + ) diff --git a/tests/test_rachio.py b/tests/test_rachio.py index cb6fac8..7140614 100644 --- a/tests/test_rachio.py +++ b/tests/test_rachio.py @@ -19,3 +19,6 @@ def test_init(self): self.assertEqual(rachio.schedulerule.authtoken, AUTHTOKEN) self.assertEqual(rachio.flexschedulerule.authtoken, AUTHTOKEN) self.assertEqual(rachio.notification.authtoken, AUTHTOKEN) + self.assertEqual(rachio.valve.authtoken, AUTHTOKEN) + self.assertEqual(rachio.summary.authtoken, AUTHTOKEN) + self.assertEqual(rachio.program.authtoken, AUTHTOKEN) diff --git a/tests/test_schedulerule.py b/tests/test_schedulerule.py index b56a727..264d9a9 100644 --- a/tests/test_schedulerule.py +++ b/tests/test_schedulerule.py @@ -32,7 +32,7 @@ def test_get(self, mock): # Check that the mock function is called with the rights args. self.assertEqual( - args[1], f"{BASE_API_URL}/schedulerule/" f"{scheduleruleid}" + args[1], f"{BASE_API_URL}/schedulerule/{scheduleruleid}" ) self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) diff --git a/tests/test_summary.py b/tests/test_summary.py new file mode 100644 index 0000000..651a0e7 --- /dev/null +++ b/tests/test_summary.py @@ -0,0 +1,47 @@ +"""Summary object test module""" + +import unittest +import uuid +import json +from unittest.mock import patch + +from rachiopy import SummaryServce +from tests.constants import VALVE_API_URL, AUTHTOKEN, RESPONSE200 + + +class TestSummaryMethod(unittest.TestCase): + """Class containing the Summary object test.""" + + def setUp(self): + self.summary = SummaryServce(AUTHTOKEN) + + def test_init(self): + """Test if the constructor works as expected.""" + self.assertEqual(self.summary.authtoken, AUTHTOKEN) + + @patch("requests.Session.request") + def test_get_valve_day_views(self, mock): + """Test if the get day views method works as expected.""" + mock.return_value = RESPONSE200 + + deviceid = str(uuid.uuid4()) + start = {"year": 2023, "month": 1, "day": 1} + end = {"year": 2023, "month": 1, "day": 30} + + self.summary.get_valve_day_views(deviceid, start, end) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/summary/getValveDayViews") + self.assertEqual(args[0], "POST") + self.assertEqual( + kwargs["data"], + json.dumps( + { + "resourceId": {"baseStationId": deviceid}, + "start": start, + "end": end, + } + ), + ) diff --git a/tests/test_valve.py b/tests/test_valve.py new file mode 100644 index 0000000..d9ab8e7 --- /dev/null +++ b/tests/test_valve.py @@ -0,0 +1,181 @@ +"""Valve object test module""" + +import unittest +from unittest.mock import patch +import uuid +import json + +from random import randrange +from rachiopy import Valve +from tests.constants import VALVE_API_URL, AUTHTOKEN, RESPONSE200, RESPONSE204 + + +class TestValveMethods(unittest.TestCase): + """Class containing the Valve object test cases.""" + + def setUp(self): + self.valve = Valve(AUTHTOKEN) + + def test_init(self): + """Test if the constructor works as expected.""" + self.assertEqual(self.valve.authtoken, AUTHTOKEN) + + @patch("requests.Session.request") + def test_get_valve(self, mock): + """Test if the get_valve method works as expected.""" + mock.return_value = RESPONSE200 + + valveid = uuid.uuid4() + + self.valve.get_valve(valveid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/valve/getValve/{valveid}") + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_get_base_station(self, mock): + """Test if the get_base_station method works as expected.""" + mock.return_value = RESPONSE200 + + baseid = str(uuid.uuid4()) + + self.valve.get_base_station(baseid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/valve/getBaseStation/{baseid}" + ) + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_list_base_stations(self, mock): + """Test if the list_base_stations method works as expected.""" + mock.return_value = RESPONSE200 + + userid = str(uuid.uuid4()) + + self.valve.list_base_stations(userid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual( + args[1], f"{VALVE_API_URL}/valve/listBaseStations/{userid}" + ) + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_list_valves(self, mock): + """Test if the list_valves method works as expected.""" + mock.return_value = RESPONSE200 + + baseid = str(uuid.uuid4()) + + self.valve.list_valves(baseid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/valve/listValves/{baseid}") + self.assertEqual(args[0], "GET") + self.assertEqual(kwargs["data"], None) + + @patch("requests.Session.request") + def test_set_default_runtime(self, mock): + """Test if the set_default_runtime method works as expected.""" + mock.return_value = RESPONSE200 + + valveid = str(uuid.uuid4()) + duration = randrange(86400) + + self.valve.set_default_runtime(valveid, duration) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/valve/setDefaultRuntime") + self.assertEqual(args[0], "PUT") + self.assertEqual( + kwargs["data"], + json.dumps( + {"valveId": valveid, "defaultRuntimeSeconds": duration} + ), + ) + + @patch("requests.Session.request") + def test_set_default_runtime_exception(self, mock): + """Test if the set_default_runtime method catches incorrect values.""" + mock.return_value = RESPONSE200 + + valveid = str(uuid.uuid4()) + duration1 = randrange(-50, -1) + duration2 = randrange(86401, 86500) + + # Check that values should be within range. + self.assertRaises( + AssertionError, self.valve.start_watering, valveid, duration1 + ) + self.assertRaises( + AssertionError, self.valve.start_watering, valveid, duration2 + ) + + @patch("requests.Session.request") + def test_start_watering(self, mock): + """Test if the start_watering method works as expected.""" + mock.return_value = RESPONSE204 + + valveid = str(uuid.uuid4()) + duration = randrange(86400) + + self.valve.start_watering(valveid, duration) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/valve/startWatering") + self.assertEqual(args[0], "PUT") + self.assertEqual( + kwargs["data"], + json.dumps({"valveId": valveid, "durationSeconds": duration}), + ) + + @patch("requests.Session.request") + def test_start_watering_exception(self, mock): + """Test if the start_watering method catches incorrect values.""" + mock.return_value = RESPONSE204 + + valveid = str(uuid.uuid4()) + duration1 = randrange(-50, -1) + duration2 = randrange(86401, 86500) + + # Check that values should be within range. + self.assertRaises( + AssertionError, self.valve.start_watering, valveid, duration1 + ) + self.assertRaises( + AssertionError, self.valve.start_watering, valveid, duration2 + ) + + @patch("requests.Session.request") + def test_stop_watering(self, mock): + """Test if the stop_watering method works as expected.""" + mock.return_value = RESPONSE204 + + valveid = str(uuid.uuid4()) + + self.valve.stop_watering(valveid) + + args, kwargs = mock.call_args + + # Check that the mock function is called with the rights args. + self.assertEqual(args[1], f"{VALVE_API_URL}/valve/stopWatering") + self.assertEqual(args[0], "PUT") + self.assertEqual(kwargs["data"], json.dumps({"valveId": valveid})) diff --git a/tests/test_zone.py b/tests/test_zone.py index a634fa0..1ed8a0d 100644 --- a/tests/test_zone.py +++ b/tests/test_zone.py @@ -46,7 +46,7 @@ def test_get(self, mock): args, kwargs = mock.call_args # Check that the mock function is called with the rights args. - self.assertEqual(args[1], f"{BASE_API_URL}/zone/" f"{zoneid}") + self.assertEqual(args[1], f"{BASE_API_URL}/zone/{zoneid}") self.assertEqual(args[0], "GET") self.assertEqual(kwargs["data"], None) diff --git a/tox.ini b/tox.ini index c724886..6980c70 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = clean, py36, py37, py38, pylint, flake8, pydocstyle, stats +envlist = clean, py{38,39,310,311,312}, pylint, flake8, pydocstyle, stats ignore_basepython_conflict = true [testenv:clean]