From bcb3d891d1c4569c7f895f1e5eddc8959a3b1932 Mon Sep 17 00:00:00 2001 From: Katherine Fleming <2205659+kflemin@users.noreply.github.com> Date: Wed, 27 Sep 2023 12:26:33 -0600 Subject: [PATCH] 0.4.0 Release (#30) * Merge main into develop after release (#21) * Release 0.3.0 (#20) * precommit * Fix delete cycle progress key and race condition (#24) * Release 0.3.0 (#20) * precommit * add progress key to delete cycles * Remove deprecated APIs, fix typos (#23) * Update README.rst * update license (#26) * Updates to support 179d, includes creating property, downloading property reports from ESPM, and updating building search(#22) * adding create_building and update_building methods * modify search_buildings to provide appropriate cycle id * adding client methods for creating extra data columns * add pass throughs for file downloads * updates for audit template workflow * method to download property xlsx --------- Co-authored-by: Alex Swindler Co-authored-by: Nicholas Long Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> * prep release * increase timeout for server start * increase timeout for server start (#31) * cleanup before release (#32) * configure seed to load small EEEJ dataset for integration test (#33) --------- Co-authored-by: Nicholas Long <1907354+nllong@users.noreply.github.com> Co-authored-by: Alex Swindler Co-authored-by: Nicholas Long --- .github/workflows/ci.yml | 27 +- .gitignore | 4 + .pre-commit-config.yaml | 6 +- CHANGELOG.rst | 25 +- LICENSE | 40 ++- README.rst | 27 +- cspell.json | 33 ++ pyseed/.pylintrc | 2 +- pyseed/apibase.py | 24 +- pyseed/seed_client.py | 297 +++++++++++++++--- pyseed/seed_client_base.py | 86 +++-- pyseed/utils.py | 12 +- setup.cfg | 12 +- .../portfolio-manager-single-22482007.xlsx | Bin 0 -> 29921 bytes tests/data/test-seed-create-columns.csv | 33 ++ tests/integration/docker-compose.yml | 9 +- tests/test_seed_base.py | 61 ++-- tests/test_seed_client.py | 173 +++++++--- tests/test_seed_client_base.py | 37 +-- tests/test_utils.py | 38 +-- 20 files changed, 689 insertions(+), 257 deletions(-) create mode 100644 cspell.json create mode 100644 tests/data/portfolio-manager-single-22482007.xlsx create mode 100644 tests/data/test-seed-create-columns.csv diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a2818df..8cd4af5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,9 +45,19 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install -r requirements-test.txt - - name: Build and run stack + - name: Install more recent docker compose + uses: ndeloof/install-compose-action@v0.0.1 + with: + version: v2.20.0 + legacy: true + - name: Print versions run: | printenv + docker --version + docker-compose --version + which docker-compose + - name: Build and run stack + run: | docker volume create --name=pyseed_media # verify that the stack wasn't cached docker-compose -f tests/integration/docker-compose.yml stop @@ -56,22 +66,27 @@ jobs: - name: Wait for web server uses: nev7n/wait_for_response@v1 with: + # Increase the timeout significanlty, the EEEJ census tract + # migration take ~6 minutes to run along. url: "http://localhost:8000/" responseCode: 200 - timeout: 120000 - interval: 2000 + timeout: 640000 + interval: 5000 - name: Wait another 30s uses: jakejarvis/wait-action@master with: time: "30s" - name: Dump docker logs before tests - uses: jwalton/gh-docker-logs@v1 + uses: jwalton/gh-docker-logs@v2 - name: Extract API credentials from SEED docker instance run: | - docker exec seed_web ./manage.py create_test_user_json --username user@seed-platform.org --host http://localhost:8000 --pyseed > seed-config.json + docker exec pyseed_web ./manage.py create_test_user_json --username user@seed-platform.org --host http://localhost:8000 --pyseed > seed-config.json - name: Run tests with pytest + env: + SEED_PM_UN: ${{ secrets.SEED_PM_UN }} + SEED_PM_PW: ${{ secrets.SEED_PM_PW }} run: | pytest -m "integration" -s - name: Dump docker logs on failure if: failure() - uses: jwalton/gh-docker-logs@v1 + uses: jwalton/gh-docker-logs@v2 diff --git a/.gitignore b/.gitignore index 4311468..01f60a9 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ coverage.xml *,cover .hypothesis/ coverage-report +tests/output # Translations *.mo @@ -99,3 +100,6 @@ ENV/ # Seed config files seed-config*.json + +*.DS_Store +*credentials.json diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 49ad510..116a486 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -22,7 +22,7 @@ repos: - id: mixed-line-ending args: ["--fix=auto"] - repo: https://github.com/pre-commit/mirrors-autopep8 - rev: v2.0.1 + rev: v2.0.4 hooks: - id: autopep8 args: @@ -35,12 +35,12 @@ repos: "--ignore=E501,E402,W503,W504,E731", ] - repo: https://github.com/pycqa/flake8 - rev: 6.0.0 + rev: 6.1.0 hooks: - id: flake8 args: ["--ignore=E501,E402,W503,W504,E731,F401"] - repo: https://github.com/pre-commit/mirrors-prettier - rev: v3.0.0-alpha.4 + rev: v3.0.3 hooks: - id: prettier types_or: [css, yaml, markdown, html, scss, javascript] diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 78584cf..6471ed2 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,11 +1,32 @@ Changelog ========= +0.4.0 +----- + +This release adds multiple helper methods to improve usability with single properties. Added methods for create_building, update_building, get_columns, create_extra_data_column, create_extra_data_column_from_file, retrieve_at_building_and_update, retrieve_portfolio_manager_property, and import_portfolio_manager_property. + +What's Changed +************** + +* Fix delete cycle progress key and race condition by @nllong in https://github.com/SEED-platform/py-seed/pull/24 +* Remove deprecated APIs, fix typos by @axelstudios in https://github.com/SEED-platform/py-seed/pull/23 +* Update license dates by @nllong in https://github.com/SEED-platform/py-seed/pull/26 +* Add new endpoints to support 179D by @kflemin in https://github.com/SEED-platform/py-seed/pull/22 +* Increase timeout for server start by @nllong in https://github.com/SEED-platform/py-seed/pull/31 + +## New Contributors +* @kflemin made their first contribution in https://github.com/SEED-platform/py-seed/pull/22 + +**Full Changelog**: https://github.com/SEED-platform/py-seed/commits/v0.4.0 + 0.3.0 ----- -## What's Changed -* Add instance info and fix a couple bugs by @nllong in https://github.com/SEED-platform/py-seed/pull/16 +What's Changed +************** + +* Add instance info and fix a couple of bugs by @nllong in https://github.com/SEED-platform/py-seed/pull/16 * Fix building list and client information by @nllong in https://github.com/SEED-platform/py-seed/pull/17 * get and create meters and meter readings by @nllong in https://github.com/SEED-platform/py-seed/pull/18 * Add GeoJSON Area Calc by @nllong in https://github.com/SEED-platform/py-seed/pull/19 diff --git a/LICENSE b/LICENSE index f0ae8ad..14d9e9a 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,42 @@ -MIT License +SEED Platform™, Copyright (c) 2017, 2023 Alliance for Sustainable Energy, LLC, and other contributors. +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted +provided that the following conditions are met: + +(1) Redistributions of source code must retain the above copyright notice, this list of +conditions and the following disclaimer. + +(2) Redistributions in binary form must reproduce the above copyright notice, this list of +conditions and the following disclaimer in the documentation and/or other materials provided +with the distribution. + +(3) Neither the name of the copyright holder nor the names of its contributors may be used +to endorse or promote products derived from this software without specific prior written +permission. + +(4) Other than as required in clauses (1) and (2), distributions in any form of modifications +or other derivative works may not use the "SEED Platform" trademark, "Standard Energy +Efficiency Data Platform", "Standard Energy Efficiency Data", "SEED", or any other confusingly +similar designation without specific prior written permission from the U.S. Department of Energy. + +(5) The name of the copyright holder(s), any contributors, the United States Government, the +United States Department of Energy, or any of their employees may not be used to endorse or +promote products derived from this software without specific prior written permission from the +respective party. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDER(S) AND ANY CONTRIBUTORS "AS IS" AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE +COPYRIGHT HOLDER(S), ANY CONTRIBUTORS, THE UNITED STATES GOVERNMENT, OR THE UNITED STATES +DEPARTMENT OF ENERGY, NOR ANY OF THEIR EMPLOYEES, BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF +SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF +THE POSSIBILITY OF SUCH DAMAGE. + +--- Copyright (c) 2017 Green Building Registry diff --git a/README.rst b/README.rst index f0a544d..38ea3bd 100644 --- a/README.rst +++ b/README.rst @@ -9,13 +9,23 @@ Py-SEED A python API client for the SEED Platform. This is an updated version of the Client. It is compatible with the latest version of the SEED Platform (>2.17.4). This client still has access to the previous format of generating a lower level API client by accessing `seed_client_base.SEEDOAuthReadOnlyClient`, `seed_client_base.SEEDOAuthReadWriteClient`, `seed_client_base.SEEDReadOnlyClient`, and `seed_client_base.SEEDReadWriteClient`. This lower level API is documented below under the `Low-Level Documentation` +Stakeholders +------------- + +The following list of stakeholders should be considered when making changes to this module + +- 179D Tax Deduction Web Application +- Earth Advantage Green Building Registry +- User scripts for managing building data +- ECAM + Documentation ------------- The SEED client is a read-write client. To install the client run: .. code-block:: bash - pip install pyseed + pip install py-seed Within Python you can use the client like this: @@ -24,7 +34,7 @@ Within Python you can use the client like this: from pathlib import Path from pyseed.seed_client import SeedClient - # The seed-config.json file defines the hosting locaiton and credentials for your SEED instance. + # The seed-config.json file defines the hosting location and credentials for your SEED instance. # If running SEED locally for testing, then you can run the following from your SEED root directory: # ./manage.py create_test_user_json --username user@seed-platform.org --host http://localhost:8000 --file ./seed-config.json --pyseed @@ -97,11 +107,22 @@ Usage: # get a single property seed_client.get(property_pk, endpoint='properties') +Local Testing +------------- + +Tests can be run via the `pytest` command. + +You will need to export environment variables for a test portfolio manager account to test integration. Environment variables should be named: + + ..code-block:: bash + + SEED_PM_UN + SEED_PM_PW License ------- -py-SEED is released under the terms of the MIT license. Full details in LICENSE file. +Full details in LICENSE file. Changelog --------- diff --git a/cspell.json b/cspell.json new file mode 100644 index 0000000..6be73e6 --- /dev/null +++ b/cspell.json @@ -0,0 +1,33 @@ +{ + "version": "0.2", + "ignorePaths": [], + "dictionaryDefinitions": [], + "dictionaries": [], + "words": [ + "apibase", + "buildingsync", + "codeauthor", + "dname", + "durl", + "ECAM", + "ESPM", + "excpt", + "geocoded", + "greenbuildingregistry", + "JSONAPI", + "Munday", + "officedocument", + "openxmlformats", + "pyseed", + "pytest", + "sdist", + "SEEDO", + "seedrecords", + "spreadsheetml", + "subclassing", + "taxlot", + "taxlots" + ], + "ignoreWords": [], + "import": [] +} diff --git a/pyseed/.pylintrc b/pyseed/.pylintrc index 5317a5b..37b8edc 100644 --- a/pyseed/.pylintrc +++ b/pyseed/.pylintrc @@ -60,7 +60,7 @@ confidence= # can either give multiple identifiers separated by comma (,) or put this # option multiple times (only on the command line, not in the configuration # file where it should appear only once).You can also use "--disable=all" to -# disable everything first and then reenable specific checks. For example, if +# disable everything first and then re-enable specific checks. For example, if # you want to run only the similarities checker, you can use "--disable=all # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes diff --git a/pyseed/apibase.py b/pyseed/apibase.py index aa44c13..4d88033 100755 --- a/pyseed/apibase.py +++ b/pyseed/apibase.py @@ -3,7 +3,7 @@ copyright (c) 2016 Earth Advantage. All rights reserved. ..codeauthor::Paul Munday -Functionality for calls to external API's""" +Functionality for calls to external APIs""" # Imports from Third Party Modules import re @@ -47,24 +47,24 @@ def __init__(self, url=None, use_ssl=True, timeout=None, use_json=False, # pylint: disable=too-many-arguments """Set url,api key, auth usage, ssl usage, timeout etc. - :param url: url to use, http(s)://can be omitted, an error will - will be used if it is supplied and dose not match use_ssl - :param: use_ssl: connect over https, defaults to true + :param url: url to use, http(s):// can be omitted, an error will + be used if it is supplied and does not match `use_ssl` + :param: use_ssl: connect over https, defaults to True :param use_auth: use authentication ..Note: - if use_auth is True the default is to use http basic + If `use_auth` is True the default is to use http basic authentication if self.auth is not set. (You will need to - to this by overriding __init__ and setting this before + do this by overriding __init__ and setting this before calling super. This requires username and password to be supplied as - keyword arguments. N.B. api keys using basic auth e.g. SEED + keyword arguments. N.B. api keys using basic auth e.g., SEED should be supplied as password. To use Digest Authentication set auth_method='digest' - If use_ssl is False and the url you supply starts with https + If `use_ssl` is False and the url you supply starts with https an error will be thrown. """ self.timeout = timeout @@ -80,7 +80,7 @@ def __init__(self, url=None, use_ssl=True, timeout=None, use_json=False, def _construct_payload(self, params): """Construct parameters for an api call. . - :param params: An dictionary of key-value pairs to include + :param params: A dictionary of key-value pairs to include in the request. :return: A dictionary of k-v pairs to send to the server in the request. @@ -319,7 +319,7 @@ def _get_auth(self): def _construct_payload(self, params): """Construct parameters for an api call. . - :param params: An dictionary of key-value pairs to include + :param params: A dictionary of key-value pairs to include in the request. :return: A dictionary of k-v pairs to send to the server in the request. @@ -355,8 +355,8 @@ def _get_access_token(self): def _construct_payload(self, params): """Construct parameters for an api call. -. - :param params: An dictionary of key-value pairs to include + + :param params: A dictionary of key-value pairs to include in the request. :return: A dictionary of k-v pairs to send to the server in the request. diff --git a/pyseed/seed_client.py b/pyseed/seed_client.py index bec4ff0..e16e8e8 100644 --- a/pyseed/seed_client.py +++ b/pyseed/seed_client.py @@ -1,39 +1,6 @@ """ -**************************************************************************************************** -:copyright (c) 2019-2022, Alliance for Sustainable Energy, LLC, and other contributors. - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted -provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this list of conditions -and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions -and the following disclaimer in the documentation and/or other materials provided with the -distribution. - -Neither the name of the copyright holder nor the names of its contributors may be used to endorse -or promote products derived from this software without specific prior written permission. - -Redistribution of this software, without modification, must refer to the software by the same -designation. Redistribution of a modified version of this software (i) may not refer to the -modified version by the same designation, or by any confusingly similar designation, and -(ii) must refer to the underlying software originally provided by Alliance as “URBANopt”. Except -to comply with the foregoing, the term “URBANopt”, or any confusingly similar designation may -not be used to refer to any modified version of this software or any modified version of the -underlying software originally provided by Alliance without the prior written consent of Alliance. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER -IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -**************************************************************************************************** +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/py-seed/main/LICENSE """ # Imports from Standard Library @@ -44,6 +11,7 @@ import logging import time from collections import Counter +from csv import DictReader from datetime import date from pathlib import Path from urllib.parse import _NetlocResultMixinStr @@ -91,7 +59,7 @@ def __init__( # favor the connection params over the config file self.payload = {} if connection_params: - # the connetion params are simply squashed on SEEDReadWriteClient init + # the connection params are simply squashed on SEEDReadWriteClient init self.payload = connection_params elif connection_config_filepath: self.payload = SeedClientWrapper.read_connection_config_file( @@ -116,7 +84,7 @@ def read_connection_config_file(cls, filepath: Path) -> dict: "api_key": "1b5ea1ee220c8628789c61d66253d90398e6ad03", "port": 8000, "use_ssl": false, - "seed_org_name: "test-org" + "seed_org_name": "test-org" } Args: @@ -213,7 +181,6 @@ def get_organizations(self, brief: bool = True) -> Dict: def get_buildings(self) -> List[dict]: total_qry = self.client.list(endpoint="properties", data_name="pagination", per_page=100) - # print(f" total: {total_qry}") # step through each page of the results buildings: List[dict] = [] for i in range(1, total_qry['num_pages'] + 1): @@ -273,10 +240,12 @@ def get_property(self, property_id: int) -> dict: ) def search_buildings( - self, identifier_filter: str = None, identifier_exact: str = None + self, identifier_filter: str = None, identifier_exact: str = None, cycle_id: int = None ) -> dict: - payload = { - "cycle": self.cycle_id, + if not cycle_id: + cycle_id = self.cycle_id + payload: Dict[str, Any] = { + "cycle": cycle_id, } if identifier_filter is not None: payload["identifier"] = identifier_filter @@ -494,6 +463,46 @@ def update_labels_of_buildings( ) return result + def create_building(self, params: dict) -> list: + """ + Creates a building with unique ID (either pm_property_id or custom_id_1 for now) + Expects params to contain a state dictionary and a cycle id + Optionally pass in a cycle ID + + Returns the created property_view id + """ + # first try matching on custom_id_1 + matching_id = params.get('state', {}).get('custom_id_1', None) + + if not matching_id: + # then try on pm_property_id + matching_id = params.get('state', {}).get('pm_property_id', None) + + if not matching_id: + raise Exception( + "This property does not have a pm_property_id or a custom_id_1 for matching...cannot create." + ) + + cycle_id = params.get('cycle_id', None) + # include appropriate cycle in search (if not using the default cycle set on the class) + buildings = self.search_buildings(identifier_exact=matching_id, cycle_id=cycle_id) + + if len(buildings) > 0: + raise Exception( + "A property matching the provided matching ID (pm_property_id or custom_id_1) already exists." + ) + + results = self.client.post(endpoint="properties", json=params) + return results + + def update_building(self, id, params: dict) -> list: + """ + Updates a building's property_view + Expects id and params to contain a state dictionary + """ + results = self.client.put(id, endpoint="properties", json=params) + return results + def get_cycles(self) -> list: """Return a list of all the cycles for the organization. @@ -590,7 +599,7 @@ def get_or_create_cycle( cycle_name = str(cycle_name) # note that this picks the first one it finds, even if there are more - # than one cycle with the name name + # than one cycle with the same name cycle_names = [cycle["name"] for cycle in cycles] counts = Counter(cycle_names) for i_cycle_name, count in counts.items(): @@ -652,7 +661,13 @@ def delete_cycle(self, cycle_id: str) -> dict: Returns: dict: """ - return self.client.delete(cycle_id, endpoint="cycles") + result = self.client.delete(cycle_id, endpoint="cycles") + progress_key = result.get("progress_key", None) + + # wait until delete is complete + result = self.track_progress_result(progress_key) + + return result def get_or_create_dataset(self, dataset_name: str) -> dict: """Get or create a SEED dataset which is used to hold @@ -697,7 +712,7 @@ def upload_datafile( { "import_file_id": 54, "success": true, - "filename": "DataforSEED_dos15.csv" + "filename": "data_for_seed.csv" } """ params = { @@ -818,7 +833,7 @@ def create_or_update_column_mapping_profile( an already existing profile if it is there. Args: - mapping_profile_name (str): cription_ + mapping_profile_name (str): profile name mappings (list): list of mappings in the form of [ { @@ -911,7 +926,7 @@ def set_import_file_column_mappings( """Sets the column mappings onto the import file record. Args: - import_file_id (int): ID of the import file of interet + import_file_id (int): ID of the import file of interest mappings (list): list of column mappings in the form of the results of column mapping profiles Returns: @@ -924,6 +939,93 @@ def set_import_file_column_mappings( json={"mappings": mappings}, ) + def get_columns(self) -> dict: + """get the list of columns. + + Returns: + dict: { + "status": "success", + "columns: [{...}] + } + """ + result = self.client.list(endpoint="columns") + return result + + def create_extra_data_column(self, column_name: str, display_name: str, inventory_type: str, column_description: str, data_type: str) -> dict: + """ create an extra data column. If column exists, skip + Args: + 'column_name': 'project_type', + 'display_name': 'Project Type', + 'inventory_type': 'Property' or 'Taxlot', + 'column_description': 'Project Type (New or Retrofit)', + 'data_type': 'string', + + Returns: + dict:{ + "status": "success", + "column": { + "id": 151, + "name": "project_type_151", + ... + } + } + """ + + # get extra data columns (only) + result = self.client.list(endpoint="columns") + columns = result['columns'] + extra_data_cols = [item for item in columns if item['is_extra_data']] + + # see if extra data column already exists (for now don't update it, just skip it) + res = list(filter(lambda extra_data_cols: extra_data_cols['column_name'] == column_name, extra_data_cols)) + if res: + # column already exists + result = {"status": "noop", "message": "column already exists"} + else: + # create + payload = { + "column_name": column_name, + "display_name": display_name, + "table_name": "PropertyState" if inventory_type == "Property" else "TaxlotState", + "column_description": column_description, + "data_type": data_type, + "organization_id": self.get_org_id() + } + result = self.client.post(endpoint="columns", json=payload) + + return result + + def create_extra_data_columns_from_file(self, columns_csv_filepath: str) -> list: + """ create extra data columns from a csv file. if column exist, skip. + Args: + 'columns_csv_filepath': 'path/to/file' + file is expected to have headers: column_name, display_name, column_description, + inventory_type (Property or Taxlot), and data_type (SEED column data_types) + + See example file at tests/data/test-seed-create-columns.csv + + Returns: + list:[{ + "status": "success", + "column": { + "id": 151, + "name": "project_type_151", + ... + } + }] + """ + # open file in read mode + with open(columns_csv_filepath, 'r') as f: + dict_reader = DictReader(f) + columns = list(dict_reader) + + results = [] + for col in columns: + result = self.create_extra_data_column(**col) + results.append(result) + + return results + def get_meters(self, property_id: int) -> list: """Return the list of meters assigned to a property (the property view id). Note that meters are attached to the property (not the state nor the property view). @@ -1006,7 +1108,7 @@ def delete_meter(self, property_view_id: int, meter_id: int) -> dict: meter_id (int): meter id Returns: - dict: status of the delete + dict: status of the deletion """ return self.client.delete( meter_id, endpoint='properties_meters', url_args={"PK": property_view_id} @@ -1016,9 +1118,9 @@ def upsert_meter_readings_bulk(self, property_view_id: int, meter_id: int, data: """Upsert meter readings for a property's meter with the bulk method. Args: - property_id (int): property id + property_view_id (int): property view id meter_id (int): meter id - data (list): list of dictioanries of meter readings + data (list): list of dictionaries of meter readings Returns: dict: list of all meter reading objects @@ -1178,7 +1280,7 @@ def check_meters_tab_exist(self, import_file_id: int) -> bool: def import_files_reuse_inventory_file_for_meters(self, import_file_id: int) -> dict: """Reuse an import file to create all the meter entries. This method is used for ESPM related data files. The result will be another import_file ID for the - meters that will then need to be "resaved". Note that the returning import_file_id + meters that will then need to be "re-saved". Note that the returning import_file_id is not the same as the argument import file. Args: @@ -1275,3 +1377,98 @@ def upload_and_match_datafile( result = self.track_progress_result(progress_key) return matching_results + + def retrieve_at_building_and_update(self, audit_template_building_id: int, cycle_id: int, seed_id: int) -> dict: + """Connect to audit template and retrieve audit XML by building ID + + Args: + audit_template_building_id (int): ID of the building in the audit template + cycle_id (int): Cycle ID in SEED + seed_id (int): PropertyView ID in SEED + + Returns: + dict: Response from the SEED API + """ + + # api/v3/audit_template/pk/get_building_xml + response = self.client.get( + None, + required_pk=False, + endpoint="audit_template_building_xml", + url_args={"PK": audit_template_building_id} + ) + + if response['status'] == 'success': + # now post to api/v3/properties/PK/update_with_buildingsync + xml_file = response['content'] + filename = 'at_' + str(int(time.time() * 1000)) + '.xml' + files = [ + ('file', (filename, xml_file)), + ('file_type', (None, 1)) + ] + + response = self.client.put( + None, + required_pk=False, + endpoint="properties_update_with_buildingsync", + url_args={"PK": seed_id}, + files=files, + cycle_id=cycle_id + ) + + return response + + def retrieve_portfolio_manager_property(self, username: str, password: str, pm_property_id: int, save_file_name: Path) -> dict: + """Connect to portfolio manager and download an individual properties data in Excel format + + Args: + username (str): ESPM login username + password (str): ESPM password + pm_property_id (int): ESPM ID of the property to download + save_file_name (Path): Location to save the file, preferably an absolute path + + Returns: + bool: Did the file download? + """ + if save_file_name.exists(): + raise Exception(f"Save filename already exists, save to a new file name: {save_file_name}") + + response = self.client.post( + "portfolio_manager_property_download", + json={"username": username, "password": password}, + url_args={"PK": pm_property_id} + ) + result = {'status': 'error'} + # save the file to the location that was passed + # note that the data are returned directly (the ESPM URL directly downloads the file) + if isinstance(response, bytes): + with open(save_file_name, 'wb') as f: + f.write(response) + result['status'] = 'success' + return result + + def import_portfolio_manager_property(self, seed_id: int, cycle_id: int, mapping_profile_id: int, file_path: str) -> dict: + """Import the downloaded xlsx file into SEED on a specific propertyID + Args: + seed_id (int): Property view ID to update with the ESPM file + cycle_id (int): Cycle ID + mapping_profile_id (int): Column Mapping Profile ID + file: path to file downloaded from the retrieve_portfolio_manager_report method above + ESPM file will have meter data that we want to handle (electricity and natural gas) + in the 'Meter Entries' tab""" + + files_params = [ + ("file", (Path(file_path).name, open(Path(file_path).resolve(), "rb"))), + ] + + response = self.client.put( + None, + required_pk=False, + endpoint="property_update_with_espm", + url_args={"PK": seed_id}, + files=files_params, + cycle_id=cycle_id, + mapping_profile_id=mapping_profile_id + ) + + return response diff --git a/pyseed/seed_client_base.py b/pyseed/seed_client_base.py index a7f7499..bffbbb0 100644 --- a/pyseed/seed_client_base.py +++ b/pyseed/seed_client_base.py @@ -15,7 +15,7 @@ This is a deliberate design decision. There is no general purpose client that can write to the db, this ensures caching is transparent and always valid. -You *must* always use the class corresponding to the relevant model, i.e. +You *must* always use the class corresponding to the relevant model, i.e., one that inherits from SEEDRecord to be able to write to the db. You *should* generally this for reading too, in order to get the benefits of caching. @@ -64,31 +64,18 @@ 'import_files_start_matching_pk': '/api/v3/import_files/PK/start_system_matching_and_geocoding/', 'import_files_check_meters_tab_exists_pk': '/api/v3/import_files/PK/check_meters_tab_exists/', 'org_column_mapping_import_file': 'api/v3/organizations/ORG_ID/column_mappings/', - 'properties_meters_reading': '/api/v3/properties/PK/meters/METER_PK/readings/', + 'portfolio_manager_property_download': '/api/v3/portfolio_manager/PK/download/', + # PUTs with replaceable keys: + 'properties_update_with_buildingsync': 'api/v3/properties/PK/update_with_building_sync/', + 'property_update_with_espm': 'api/v3/properties/PK/update_with_espm/', # GETs with replaceable keys 'import_files_matching_results': '/api/v3/import_files/PK/matching_and_geocoding_results/', 'progress': '/api/v3/progress/PROGRESS_KEY/', 'properties_meters': '/api/v3/properties/PK/meters/', 'properties_meter_usage': '/api/v3/properties/PK/meter_usage/', + 'audit_template_building_xml': '/api/v3/audit_template/PK/get_building_xml', + # GET & POST with replaceable keys 'properties_meters_reading': '/api/v3/properties/PK/meters/METER_PK/readings/', - }, - 'v2': { - 'columns': '/api/v2/columns/', - 'column_mappings': '/api/v2/column_mappings/', - 'cycles': '/api/v2/cycles/', - 'datasets': '/api/v2/datasets/', - 'gbr_properties': '/api/v2/gbr_properties/', - 'green_assessment': '/api/v2/green_assessments/', - 'green_assessment_property': '/api/v2/green_assessment_properties/', - 'green_assessment_url': '/api/v2/green_assessment_urls/', - 'labels': '/api/v2/labels/', - 'import_files': '/api/v2/import_files/', - 'projects': '/api/v2/projects/', - 'properties': '/api/v2/properties/', - 'property_states': '/api/v2/property_states/', - 'property_views': '/api/v2/property_views/', - 'taxlots': '/api/v2/taxlots/', - 'users': '/api/v2/users/', } } @@ -141,14 +128,14 @@ class SEEDBaseClient(JSONAPI): can inherit from them directly and overwrite methods/use mixins as appropriate. - endpoint refers to the endpoint name. This allow you to call an + endpoint refers to the endpoint name. This allows you to call an endpoint without having to know the full url. Endpoint names are set in config, and can be accessed as self.endpoints. data_name is set as an attribute on the view called. This constrains the actual response data. - If not set it is derived from the url (typically its the view name). + If not set it is derived from the url (typically it's the view name). In either case 'data' is used as a fallback, then detail. This is an annoyance, but SEED adds an unnecessary 'status' @@ -162,7 +149,7 @@ class SEEDBaseClient(JSONAPI): :type username: string (email address) :param api_key: api_key of use who can access records :type api_key: string - :param endpoint: seed endpoint e.g properties for /api/v2/properties/ + :param endpoint: seed endpoint e.g., properties for /api/v3/properties/ :type endpoint: string :param data_name: name of json key in api results containing data not always needed @@ -207,22 +194,42 @@ def _check_response(self, response, *args, **kwargs): """Verify we have got a response without any errors. *Never* call this directly in your methods, - *Always use self._get() etc, otherwise errors will not + *Always use self._get() etc., otherwise errors will not be reported correctly. """ error = False error_msg = 'Unknown error from SEED API' - # OK, Created, Accepted - if response.status_code not in [200, 201, 202]: + + # grab the response content type to determine json, spreadsheet, or text + response_content_types = response.headers.get('Content-Type', []) + + # OK, Created, Accepted, No-Content + if response.status_code not in [200, 201, 202, 204]: error = True error_msg = 'SEED returned status code: {}'.format(response.status_code) # SEED adds a status key to the response to indicate success/error # This is superfluous as status codes should be used to indicate an # error, but they are not always set correctly. + elif response.status_code == 204: + # there will not be response content with a 204 + error = False + elif 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in response_content_types: + # spreadsheet response + error = False + elif 'application/json' not in response_content_types: + # get as text + if not response.content: + error = True elif isinstance(response.json(), dict): status_field = response.json().get('status', None) + has_progress_key = 'progress_key' in response.json().keys() if status_field: - if status_field == 'error': + if has_progress_key: + # For the delete cycles, the data returned have a status and a progress_key, + # but no progress_data. In lieu of updating SEED, this check is added + # specifically for this case + error = status_field not in ['not-started', 'success', 'parsing'] + elif status_field == 'error': error = True elif status_field == 'success': # continue @@ -269,6 +276,15 @@ def _get_result(self, response, data_name=None, **kwargs): tries to determine what the first element of the resulting JSON is which is then used as the base for the rest of the response. This is not always desired, so pass data_name='all' if you want to get the entire response back.""" + + # grab the response content type to determine json, spreadsheet, or text + response_content_types = response.headers.get('Content-Type', []) + + # pass through for spreadsheet (?) + if 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' in response_content_types: + return response.content + if 'application/json' not in response_content_types: + return {'status': 'success', 'content': response.content} if not data_name: url = response.request.url # take the last part of the url unless it's a digit @@ -279,7 +295,12 @@ def _get_result(self, response, data_name=None, **kwargs): else: data_name = durl[1] # actual results should be under data_name or the fallbacks - result = response.json() + # handle a 204 + result = None + if response.status_code == 204: + result = {'status': 'success'} + else: + result = response.json() if result is None: error_msg = 'No results returned' self._raise_error(response, error_msg, stack_pos=2, **kwargs) @@ -308,14 +329,14 @@ def _raise_error(self, response, error_msg, stack_pos=0, *args, **kwargs): """ Raise SEEDError on bad response. - This method is intended for use only by self_get() etc and the methods + This method is intended for use only by self_get(), etc., and the methods called there. For most purposes you should raise SEEDError directly. This method uses the inspect module to derive the method name. stack_pos indicates where in the stack to find this: it corresponds to the depth of function calls. - Thus if the error occurs directly in the function calling _raise_error + Thus, if the error occurs directly in the function calling _raise_error stack_pos=0, if that function is called by another function add 1 etc. Note technically *this* method (_raise_error) is at the bottom of the stack, but we add 1 to stack_pos so counting starts at the method @@ -330,7 +351,7 @@ def _raise_error(self, response, error_msg, stack_pos=0, *args, **kwargs): status_code = response.status_code url = response.request.url verb = response.request.method - # e.g. MyClass.method + # e.g., MyClass.method caller = caller = '{}.{}'.format( self.__class__.__name__, inspect.stack()[stack_pos + 1][3] ) @@ -504,9 +525,10 @@ def delete(self, pk, endpoint=None, data_name=None, **kwargs): url = add_pk(self.urls[endpoint], pk, required=kwargs.pop('required_pk', True), slash=True) url = _replace_url_args(url, url_args) response = super(DeleteMixin, self)._delete(url=url, **kwargs) - # delete should return 204 and no content + # delete should return 204 and no content, unless it is a background task if response.status_code != requests.codes.no_content: self._check_response(response, **kwargs) + return self._get_result(response, data_name=data_name, **kwargs) class SEEDReadOnlyClient(ReadMixin, UserAuthMixin, SEEDBaseClient): diff --git a/pyseed/utils.py b/pyseed/utils.py index 9a501ea..e10fd59 100644 --- a/pyseed/utils.py +++ b/pyseed/utils.py @@ -1,4 +1,7 @@ -# from __future__ import division +""" +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/py-seed/main/LICENSE +""" # Imports from Third Party Modules import csv @@ -14,10 +17,9 @@ def _rad(value): def _ring_area(coordinates): - """ - Calculate the approximate total_area of the polygon were it projected onto - the earth. Note that this _area will be positive if ring is oriented - clockwise, otherwise it will be negative. + """Calculate the approximate total_area of the polygon were it projected onto + the earth. Note that this _area will be positive if ring is oriented + clockwise, otherwise it will be negative. Reference: Robert. G. Chamberlain and William H. Duquette, "Some Algorithms for diff --git a/setup.cfg b/setup.cfg index bb2fbf5..d9c908f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,20 +1,20 @@ [metadata] name=py-seed -version=0.3.0 +version=0.4.0 description=A Python API client for the SEED Platform -author=Fable Turas, Paul Munday, Nicholas Long -author_email=fable@raintechpdx.com, paul@paulmunday.net, nicholas.long@nrel.gov -maintainer=GreenBuildingRegistry, NREL -maintainer_email=admin@greenbuildingregistry.com +author=Nicholas Long, Katherine Fleming, Fable Turas, Paul Munday +author_email=nicholas.long@nrel.gov, fable@raintechpdx.com, paul@paulmunday.net +maintainer=NREL +maintainer_email=nicholas.long@nrel.gov keywords= seed, api url=https://github.com/seed-platform/py-seed classifiers = Development Status :: 4 - Beta Intended Audience :: Developers Operating System :: OS Independent - Programming Language :: Python :: 3.8 Programming Language :: Python :: 3.9 Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.11 [options] packages = find: diff --git a/tests/data/portfolio-manager-single-22482007.xlsx b/tests/data/portfolio-manager-single-22482007.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..0317d2c7f3b294c93fd8e617cfa7933c832fe321 GIT binary patch literal 29921 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 a_1-XB@k>l-^=S9e?h61o= 1 + + def test_create_column(self): + result = self.seed_client.create_extra_data_column( + column_name='test_col', + display_name='A Test Column', + inventory_type="Property", + column_description="this is a test column", + data_type="string") + assert result['status'] == 'success' + assert 'id' in result['column'] + + def test_create_columns_from_file(self): + cols_filepath = 'tests/data/test-seed-create-columns.csv' + result = self.seed_client.create_extra_data_columns_from_file(cols_filepath) + assert len(result) + assert result[0]['status'] + def test_get_column_mapping_profiles(self): result = self.seed_client.get_column_mapping_profiles() assert len(result) >= 1 @@ -148,7 +137,7 @@ def test_get_column_mapping_profile(self): result = self.seed_client.get_column_mapping_profile('does not exist') assert result is None - # There should always be a portolio manager default unless the + # There should always be a portfolio manager default unless the # user removed it. result = self.seed_client.get_column_mapping_profile('Portfolio Manager Defaults') assert isinstance(result, dict) diff --git a/tests/test_seed_client.py b/tests/test_seed_client.py index a3855a6..1bf2766 100644 --- a/tests/test_seed_client.py +++ b/tests/test_seed_client.py @@ -1,42 +1,10 @@ """ -**************************************************************************************************** -:copyright (c) 2019-2022, Alliance for Sustainable Energy, LLC, and other contributors. - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted -provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this list of conditions -and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions -and the following disclaimer in the documentation and/or other materials provided with the -distribution. - -Neither the name of the copyright holder nor the names of its contributors may be used to endorse -or promote products derived from this software without specific prior written permission. - -Redistribution of this software, without modification, must refer to the software by the same -designation. Redistribution of a modified version of this software (i) may not refer to the -modified version by the same designation, or by any confusingly similar designation, and -(ii) must refer to the underlying software originally provided by Alliance as “URBANopt”. Except -to comply with the foregoing, the term “URBANopt”, or any confusingly similar designation may -not be used to refer to any modified version of this software or any modified version of the -underlying software originally provided by Alliance without the prior written consent of Alliance. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER -IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -**************************************************************************************************** +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/py-seed/main/LICENSE """ # Imports from Third Party Modules +import os import pytest import unittest from datetime import date @@ -92,10 +60,15 @@ def test_seed_client_info(self): assert set(("version", "sha")).issubset(info.keys()) def test_seed_buildings(self): + # set cycle before retrieving (just in case) + self.seed_client.get_cycle_by_name('pyseed-api-test', set_cycle_id=True) buildings = self.seed_client.get_buildings() + # ESPM test creates a building now too, assert building count is 10 or 11? assert len(buildings) == 10 def test_search_buildings(self): + # set cycle + self.seed_client.get_cycle_by_name('pyseed-api-test', set_cycle_id=True) properties = self.seed_client.search_buildings(identifier_exact="B-1") assert len(properties) == 1 @@ -106,7 +79,6 @@ def test_search_buildings(self): # test the property view (same as previous, just less data). It # is recommended to use `get_property` instead. prop = self.seed_client.get_property_view(properties[0]["id"]) - print(prop) assert prop["id"] == properties[0]["id"] assert prop["cycle"]["name"] == "pyseed-api-test" @@ -114,6 +86,66 @@ def test_search_buildings(self): properties = self.seed_client.search_buildings(identifier_filter="B-1") assert len(properties) == 2 + def test_create_update_building(self): + # create a new building (property, propertyState, propertyView) + # Update the building + completion_date = "02/02/2023" + year = '2023' + cycle = self.seed_client.get_or_create_cycle( + "pyseed-api-integration-test", + date(int(year), 1, 1), + date(int(year), 12, 31), + set_cycle_id=True, + ) + + state = { + "organization_id": self.organization_id, + "custom_id_1": "123456", + "address_line_1": "123 Testing St", + "city": "Beverly Hills", + "state": "CA", + "postal_code": "90210", + "property_name": "Test Building", + "property_type": None, + "gross_floor_area": None, + "conditioned_floor_area": None, + "occupied_floor_area": None, + "site_eui": None, + "site_eui_modeled": None, + "source_eui_weather_normalized": None, + "source_eui": None, + "source_eui_modeled": None, + "site_eui_weather_normalized": None, + "total_ghg_emissions": None, + "total_marginal_ghg_emissions": None, + "total_ghg_emissions_intensity": None, + "total_marginal_ghg_emissions_intensity": None, + "generation_date": None, + "recent_sale_date": None, + "release_date": None, + "extra_data": { + "pathway": "new", + "completion_date": completion_date + } + } + + params = {'state': state, 'cycle_id': cycle["id"]} + + result = self.seed_client.create_building(params=params) + assert result["status"] == "success" + assert result["view"]["id"] is not None + view_id = result["view"]["id"] + + # update that property (by ID) + state['property_name'] = 'New Name Building' + + properties = self.seed_client.search_buildings(identifier_exact=state['custom_id_1']) + assert len(properties) == 1 + + params2 = {'state': state} + result2 = self.seed_client.update_building(view_id, params=params2) + assert result2["status"] == "success" + def test_add_label_to_buildings(self): # get seed buildings prop_ids = [] @@ -285,9 +317,68 @@ def test_upload_single_method_with_meters(self): meter_data = self.seed_client.get_meter_data(building[0]["id"]) assert len(meter_data['readings']) == 24 - # def test_get_buildings_with_labels(self): - # buildings = self.seed_client.get_view_ids_with_label(['In Violation', 'Compliant', 'Email']) - # for building in buildings: - # print(building) + def test_download_espm_property(self): + # For testing, read in the ESPM username and password from + # environment variables. - # assert len(buildings) == 3 + save_file = self.output_dir / "espm_test_22178850.xlsx" + if save_file.exists(): + save_file.unlink() + + self.seed_client.retrieve_portfolio_manager_property( + username=os.environ.get('SEED_PM_UN'), + password=os.environ.get('SEED_PM_PW'), + pm_property_id=22178850, + save_file_name=save_file + ) + + self.assertTrue(save_file.exists()) + + # redownload and show an error + with self.assertRaises(Exception) as excpt: + self.seed_client.retrieve_portfolio_manager_property( + username=os.environ.get('SEED_PM_UN'), + password=os.environ.get('SEED_PM_PW'), + pm_property_id=22178850, + save_file_name=save_file + ) + + self.assertEqual( + str(excpt.exception), + f'Save filename already exists, save to a new file name: {str(save_file)}' + ) + + def test_upload_espm_property_to_seed(self): + + file = Path("tests/data/portfolio-manager-single-22482007.xlsx") + + # need a building + buildings = self.seed_client.get_buildings() + building = None + if buildings: + building = buildings[0] + self.assertTrue(building) + + # need a column mapping profile + mapping_file = Path("tests/data/test-seed-data-mappings.csv") + mapping_profile = self.seed_client.create_or_update_column_mapping_profile_from_file('ESPM Test', mapping_file) + self.assertTrue('id' in mapping_profile) + + response = self.seed_client.import_portfolio_manager_property(building['id'], self.seed_client.cycle_id, mapping_profile['id'], file) + self.assertTrue(response['status'] == 'success') + + # def test_retrieve_at_building_and_update(self): + # # NOTE: commenting this out as we cannot set the AT credentials in SEED from py-seed + + # # need a building + # buildings = self.seed_client.get_buildings() + # building = None + # if buildings: + # building = buildings[0] + # self.assertTrue(building) + + # # need an Audit Template Building ID (use envvar for this) + # at_building_id=os.environ.get('SEED_AT_BUILDING_ID'), + + # response = self.seed_client.retrieve_at_building_and_update(self, at_building_id, self.cycle_id, building['id']) + # self.assertTrue(response['status'] == 'success') diff --git a/tests/test_seed_client_base.py b/tests/test_seed_client_base.py index ac87dfe..276bcf3 100644 --- a/tests/test_seed_client_base.py +++ b/tests/test_seed_client_base.py @@ -1,10 +1,6 @@ -#!/usr/bin/env python -# encoding: utf-8 """ -copyright (c) 2016 Earth Advantage. -All rights reserved - -Tests for SEEDClient +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/py-seed/main/LICENSE """ # Imports from Third Party Modules @@ -24,9 +20,9 @@ # Constants URLS = { - 'test1': 'api/v2/test', - 'test2': 'api/v2/test2', - 'test3': 'api/v2/test3', + 'test1': 'api/v3/test', + 'test2': 'api/v3/test2', + 'test3': 'api/v3/test3', } CONFIG_DICT = { @@ -98,6 +94,8 @@ def get_mock_response(data=None, data_name='data', error=False, mock_response = mock.MagicMock() mock_response.status_code = status_code mock_response.request = mock_request + mock_response.headers = {'Content-Type': 'application/json'} + # SEED old style if content: if error: @@ -135,13 +133,13 @@ def test_check_response_inheritance(self, mock_requests): """ Ensure errors are correctly reported. - SEEDError should show the calling method where the error occured. + SEEDError should show the calling method where the error occurred. It uses the inspect module to get the calling method from the stack. Error called in _check_response(), this also tests that method as well as _raise_error(). """ - url = 'http://example.org/api/v2/test/' + url = 'http://example.org/api/v3/test/' # Old SEED Style 200 (sic) with error message mock_requests.get.return_value = get_mock_response( data="No llama!", error=True @@ -252,7 +250,7 @@ def setUp(self): def test_delete(self, mock_requests): # pylint:disable=no-member - url = 'https://example.org:1337/api/v2/test/1/' + url = 'https://example.org:1337/api/v3/test/1/' mock_requests.delete.return_value = get_mock_response( status_code=requests.codes.no_content ) @@ -261,21 +259,21 @@ def test_delete(self, mock_requests): mock_requests.delete.assert_called_with(url, **self.call_dict) def test_get(self, mock_requests): - url = 'https://example.org:1337/api/v2/test/1/' + url = 'https://example.org:1337/api/v3/test/1/' mock_requests.get.return_value = get_mock_response(data="Llama!") result = self.client.get(1, endpoint='test1') self.assertEqual('Llama!', result) mock_requests.get.assert_called_with(url, **self.call_dict) def test_list(self, mock_requests): - url = 'https://example.org:1337/api/v2/test/' + url = 'https://example.org:1337/api/v3/test/' mock_requests.get.return_value = get_mock_response(data=["Llama!"]) result = self.client.list(endpoint='test1') self.assertEqual(['Llama!'], result) mock_requests.get.assert_called_with(url, **self.call_dict) def test_patch(self, mock_requests): - url = 'https://example.org:1337/api/v2/test/1/' + url = 'https://example.org:1337/api/v3/test/1/' mock_requests.patch.return_value = get_mock_response(data="Llama!") result = self.client.patch(1, endpoint='test1', foo='bar', json={'more': 'data'}) self.assertEqual('Llama!', result) @@ -292,7 +290,7 @@ def test_patch(self, mock_requests): mock_requests.patch.assert_called_with(url, **expected) def test_put(self, mock_requests): - url = 'https://example.org:1337/api/v2/test/1/' + url = 'https://example.org:1337/api/v3/test/1/' mock_requests.put.return_value = get_mock_response(data="Llama!") result = self.client.put(1, endpoint='test1', foo='bar', json={'more': 'data'}) self.assertEqual('Llama!', result) @@ -309,11 +307,10 @@ def test_put(self, mock_requests): mock_requests.put.assert_called_with(url, **expected) def test_post(self, mock_requests): - url = 'https://example.org:1337/api/v2/test/' + url = 'https://example.org:1337/api/v3/test/' mock_requests.post.return_value = get_mock_response(data="Llama!") result = self.client.post(endpoint='test1', json={'foo': 'bar', 'not_org': 1}) self.assertEqual('Llama!', result) - expected = { 'headers': {'Authorization': 'Bearer dfghjk'}, 'params': { @@ -347,13 +344,13 @@ def setUp(self): } def test_get(self, mock_requests): - # url = 'https://example.org:1337/api/v2/test/1/' + # url = 'https://example.org:1337/api/v3/test/1/' mock_requests.get.return_value = get_mock_response(data="Llama!") result = self.client.get(1, endpoint='test1') self.assertEqual('Llama!', result) def test_list(self, mock_requests): - # url = 'https://example.org:1337/api/v2/test/' + # url = 'https://example.org:1337/api/v3/test/' mock_requests.get.return_value = get_mock_response(data=["Llama!"]) result = self.client.list(endpoint='test1') self.assertEqual(['Llama!'], result) diff --git a/tests/test_utils.py b/tests/test_utils.py index 370876c..e7d1cc4 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,41 +1,9 @@ """ -**************************************************************************************************** -:copyright (c) 2019-2022, Alliance for Sustainable Energy, LLC, and other contributors. - -All rights reserved. - -Redistribution and use in source and binary forms, with or without modification, are permitted -provided that the following conditions are met: - -Redistributions of source code must retain the above copyright notice, this list of conditions -and the following disclaimer. - -Redistributions in binary form must reproduce the above copyright notice, this list of conditions -and the following disclaimer in the documentation and/or other materials provided with the -distribution. - -Neither the name of the copyright holder nor the names of its contributors may be used to endorse -or promote products derived from this software without specific prior written permission. - -Redistribution of this software, without modification, must refer to the software by the same -designation. Redistribution of a modified version of this software (i) may not refer to the -modified version by the same designation, or by any confusingly similar designation, and -(ii) must refer to the underlying software originally provided by Alliance as “URBANopt”. Except -to comply with the foregoing, the term “URBANopt”, or any confusingly similar designation may -not be used to refer to any modified version of this software or any modified version of the -underlying software originally provided by Alliance without the prior written consent of Alliance. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR -IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR -CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER -IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT -OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -**************************************************************************************************** +SEED Platform (TM), Copyright (c) Alliance for Sustainable Energy, LLC, and other contributors. +See also https://github.com/seed-platform/py-seed/main/LICENSE """ + # Imports from Third Party Modules import unittest from pathlib import Path