From 87756ceb6dd2f7fd655b5732fdb7958f002b610e Mon Sep 17 00:00:00 2001 From: Andrey Sinitsyn Date: Thu, 26 Sep 2024 19:37:35 +0200 Subject: [PATCH] Fix installation, added typing hints, fix tests, add docs, added as_json to fetch to return response.json() result --- .gitignore | 3 +- README.md | 25 +++++- pyproject.toml | 27 +++++- src/waqi-python-client/entities.py | 78 ------------------ .../__init__.py | 0 src/waqi_python_client/entities.py | 82 +++++++++++++++++++ .../waqi_api.py | 6 +- tests/waqi_api_test.py | 59 +++++++++++-- 8 files changed, 186 insertions(+), 94 deletions(-) delete mode 100644 src/waqi-python-client/entities.py rename src/{waqi-python-client => waqi_python_client}/__init__.py (100%) create mode 100644 src/waqi_python_client/entities.py rename src/{waqi-python-client => waqi_python_client}/waqi_api.py (78%) diff --git a/.gitignore b/.gitignore index e4469a2..bfd5adb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ index.py .vscode -__pycache__ \ No newline at end of file +__pycache__ +.idea \ No newline at end of file diff --git a/README.md b/README.md index 2de8561..a4a8d59 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,16 @@ All available API modules are supported - City feed, Geolocalized feed, Search, ### Installation -Coming soon... +The project is managed using Hatch ([documentation](https://hatch.pypa.io/latest/install/)). After Hatch is installed, proceed with commands + +```bash + +hatch env create # Creates virtual environment +hatch shell # Enable virtual environment +hatch build . # Builds the package wheel and sdist artifacts +``` + +It will provide the sdist and wheel artifacts that later can be installed via `pip`. ### Get API key @@ -15,8 +24,11 @@ Sign up for an API key [here](https://aqicn.org/data-platform/token/) The primary `WaqiAPI` class in the `waqi_api` module is a factory class that creates objects for each of the API modules, allowing you to make requests to any of them with your desired request parameters. You have to first create an object for it, then access your desired API module via the object. See the code snippets below: -```py -from waqi_api import WaqiAPI +```python + +WAQI_TOKEN = 'Obtained API key' + +from waqi_python_client.waqi_api import WaqiAPI api = WaqiAPI(WAQI_TOKEN) ``` @@ -50,3 +62,10 @@ response = api.ip_feed().set_ip("MY_IP").fetch() ```py response = api.map_station().set_map_bounds(40.712, -74.006, 34.052, -118.243).fetch() ``` + +## Tests + +```bash +hatch shell +hatch test +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index b9a9186..e544097 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,9 @@ build-backend = "hatchling.build" name = "waqi-python-client" version = "0.0.1" authors = [ - { name="Nonso Mgbechi", email="mgbechinonso@gmail.com" }, - { name="Niyi Omotoso", email="omoniyiomotoso@gmail.com" }, + { name="Nonso Mgbechi", email="mgbechinonso@gmail.com" }, + { name="Niyi Omotoso", email="omoniyiomotoso@gmail.com" }, + { name="Andrey Sinitsyn", email="ollamh+waqi@gmail.com" }, ] description = "Python client library for the World Air Quality Index (WAQI) APIs" readme = "README.md" @@ -17,7 +18,27 @@ classifiers = [ "License :: OSI Approved :: MIT License", "Operating System :: OS Independent", ] +dependencies = [ + "requests", +] [project.urls] "Homepage" = "https://github.com/waqi-dev-community/waqi-python-client" -"Bug Tracker" = "https://github.com/waqi-dev-community/waqi-python-client/issues" \ No newline at end of file +"Bug Tracker" = "https://github.com/waqi-dev-community/waqi-python-client/issues" + +[tool.hatch.build] +packages = ["src/waqi_python_client"] + +[tool.hatch.build.targets.wheel] +packages = ["src/waqi_python_client"] + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.pytest.ini_options] +pythonpath = [ + "." +] diff --git a/src/waqi-python-client/entities.py b/src/waqi-python-client/entities.py deleted file mode 100644 index 59aa9f1..0000000 --- a/src/waqi-python-client/entities.py +++ /dev/null @@ -1,78 +0,0 @@ -import requests - -class WaqiAPIEntity: - def __init__(self, api_key): - self.api_key = api_key - self.base_url = "https://api.waqi.info/" - self.query_params = {} - - def set_query_param(self, param, value): - self.query_params[param] = value - return self # Return the class object to enable method chaining - - def build_query_params(self): - if not self.query_params: - return '' - return '&' + '&'.join([f'{param}={value}' for param, value in self.query_params.items()]) - - def fetch(self): - url = self.url() + '?token=' + self.api_key + self.build_query_params() - print(url) - response = requests.get(url) - return response.text - - def url(self): - raise NotImplementedError("Subclasses must implement url()") - - -class CityFeed(WaqiAPIEntity): - def set_city(self, city): - self.query_params['city'] = city - return self # Return the class object to enable method chaining - - def url(self): - # /feed/:city/?token=:token - url = f'{self.base_url}feed/{self.query_params["city"]}/' - self.query_params = {} - return url - - -class Search(WaqiAPIEntity): - def set_keyword(self, keyword): - self.query_params['keyword'] = keyword - return self # Return the class object to enable method chaining - - def url(self): - return f'{self.base_url}search/' - -class GeoFeed(WaqiAPIEntity): - def set_coordinates(self, latitude, longitude): - self.query_params['lat'] = latitude - self.query_params['lon'] = longitude - return self # Return the class object to enable method chaining - - def url(self): - # /feed/geo::lat;:lng/?token=:token - url = f'{self.base_url}feed/geo:{self.query_params["lat"]};{self.query_params["lon"]}/' - self.query_params = {} - return url - - -class MapStation(WaqiAPIEntity): - def set_map_bounds(self, latitude_north, longitude_west, latitude_south, longitude_east): - self.query_params['latlng'] = f"{latitude_north},{longitude_west},{latitude_south},{longitude_east}" - return self # Return the class object to enable method chaining - - def url(self): - return f'{self.base_url}map/bounds/' - - -class IPFeed(WaqiAPIEntity): - def set_ip(self, ip_address): - self.query_params['ip'] = ip_address - return self - - def url(self): - return f"{self.base_url}feed/here/" - - diff --git a/src/waqi-python-client/__init__.py b/src/waqi_python_client/__init__.py similarity index 100% rename from src/waqi-python-client/__init__.py rename to src/waqi_python_client/__init__.py diff --git a/src/waqi_python_client/entities.py b/src/waqi_python_client/entities.py new file mode 100644 index 0000000..724ccdd --- /dev/null +++ b/src/waqi_python_client/entities.py @@ -0,0 +1,82 @@ +import requests +from typing import AnyStr, Any, Dict, Self, Union + +BASE_URL = "https://api.waqi.info/" + + +class WaqiAPIEntity: + def __init__(self, api_key: AnyStr) -> None: + self.api_key = api_key + self.base_url = BASE_URL + self.query_params = {} + + def set_query_param(self, param: AnyStr, value: Any) -> Self: + self.query_params[param] = value + return self # Return the class object to enable method chaining + + def set_multiple_query_params(self, **kwargs) -> Self: + self.query_params.update(kwargs) + return self + + def build_query_params(self) -> AnyStr: + if not self.query_params: + return '' + return '&' + '&'.join([f'{param}={value}' for param, value in self.query_params.items()]) + + def fetch(self, as_json=False) -> Union[AnyStr, Dict[AnyStr, Any]]: + url = self.url() + '?token=' + self.api_key + self.build_query_params() + response = requests.get(url) + return response.json() if as_json else response.text + + def url(self) -> AnyStr: + raise NotImplementedError("Subclasses must implement url()") + + +class CityFeed(WaqiAPIEntity): + def set_city(self, city: AnyStr) -> Self: + return self.set_query_param('city', city) + + def url(self) -> AnyStr: + # /feed/:city/?token=:token + url = f'{self.base_url}feed/{self.query_params["city"]}/' + self.query_params = {} + return url + + +class Search(WaqiAPIEntity): + def set_keyword(self, keyword: AnyStr) -> Self: + return self.set_query_param('keyword', keyword) + + def url(self) -> AnyStr: + return f'{self.base_url}search/' + + +class GeoFeed(WaqiAPIEntity): + def set_coordinates(self, latitude: Union[int, float], longitude: Union[int, float]) -> Self: + return self.set_multiple_query_params(lat=latitude, lon=longitude) + + def url(self) -> AnyStr: + # /feed/geo::lat;:lng/?token=:token + url = f'{self.base_url}feed/geo:{self.query_params["lat"]};{self.query_params["lon"]}/' + self.query_params = {} + return url + + +class MapStation(WaqiAPIEntity): + def set_map_bounds( + self, latitude_north: Union[int, float], longitude_west: Union[int, float], + latitude_south: Union[int, float], longitude_east: Union[int, float]): + return self.set_query_param( + 'latlng', f"{latitude_north},{longitude_west},{latitude_south},{longitude_east}") + + + def url(self) -> AnyStr: + return f'{self.base_url}map/bounds/' + + +class IPFeed(WaqiAPIEntity): + def set_ip(self, ip_address: AnyStr): + return self.set_query_param('ip', ip_address) + + def url(self) -> AnyStr: + return f"{self.base_url}feed/here/" diff --git a/src/waqi-python-client/waqi_api.py b/src/waqi_python_client/waqi_api.py similarity index 78% rename from src/waqi-python-client/waqi_api.py rename to src/waqi_python_client/waqi_api.py index 5aebfed..31f2920 100644 --- a/src/waqi-python-client/waqi_api.py +++ b/src/waqi_python_client/waqi_api.py @@ -1,4 +1,6 @@ -from entities import CityFeed, IPFeed, Search, GeoFeed, MapStation +from .entities import CityFeed, IPFeed, Search, GeoFeed, MapStation + + # Factory class for creating API objects class WaqiAPI: def __init__(self, api_key): @@ -17,4 +19,4 @@ def ip_feed(self): return IPFeed(self.api_key) def map_station(self): - return MapStation(self.api_key) \ No newline at end of file + return MapStation(self.api_key) diff --git a/tests/waqi_api_test.py b/tests/waqi_api_test.py index 2e6c8fe..3db8b77 100644 --- a/tests/waqi_api_test.py +++ b/tests/waqi_api_test.py @@ -1,13 +1,9 @@ import unittest import requests from unittest.mock import patch, Mock -import sys - -# replace the path below with your own path to run this -sys.path.append('/home/nonso/Documents/Projects/AQI_SDKs/waqi-python-client/src/waqi-python-client') # Import the classes from your Python code -from waqi_api import WaqiAPI +from src.waqi_python_client.waqi_api import WaqiAPI class WaqiAPITest(unittest.TestCase): @@ -31,6 +27,19 @@ def test_city_feed_api(self, mock_get): self.assertEqual(response_city, '{"data": "city_feed_data"}') mock_get.assert_called_once_with(f'https://api.waqi.info/feed/{city}/?token={self.api_key}') + @patch.object(requests, 'get') + def test_city_feed_api_json(self, mock_get): + mock_response = Mock() + mock_response.json.return_value = {"data": "city_feed_data"} + mock_get.return_value = mock_response + + city_feed_api = self.factory.city_feed() + city = 'example_city' + response_city = city_feed_api.set_city(city).fetch(as_json=True) + + self.assertEqual(response_city, {"data": "city_feed_data"}) + mock_get.assert_called_once_with(f'https://api.waqi.info/feed/{city}/?token={self.api_key}') + @patch.object(requests, 'get') def test_search_api(self, mock_get): mock_response = Mock() @@ -44,6 +53,19 @@ def test_search_api(self, mock_get): self.assertEqual(response_search, '{"data": "search_data"}') mock_get.assert_called_once_with(f'https://api.waqi.info/search/?token={self.api_key}&keyword={keyword}') + @patch.object(requests, 'get') + def test_search_api_json(self, mock_get): + mock_response = Mock() + mock_response.json.return_value = {"data": "search_data"} + mock_get.return_value = mock_response + + search_api = self.factory.search() + keyword = "Johannesburg" + response_search = search_api.set_keyword(keyword).fetch(as_json=True) + + self.assertEqual(response_search, {"data": "search_data"}) + mock_get.assert_called_once_with(f'https://api.waqi.info/search/?token={self.api_key}&keyword={keyword}') + @patch.object(requests, 'get') def test_geo_feed_api(self, mock_get): mock_response = Mock() @@ -59,6 +81,21 @@ def test_geo_feed_api(self, mock_get): self.assertEqual(response_geo, '{"data": "geo_feed_data"}') mock_get.assert_called_once_with(f'https://api.waqi.info/feed/geo:{lat};{lon}/?token={self.api_key}') + @patch.object(requests, 'get') + def test_geo_feed_api_json(self, mock_get): + mock_response = Mock() + mock_response.json.return_value = {"data": "geo_feed_data"} + mock_get.return_value = mock_response + + geo_feed_api = self.factory.geo_feed() + + lat = 37.7749 + lon = -122.4194 + response_geo = geo_feed_api.set_coordinates(lat, lon).fetch(as_json=True) + + self.assertEqual(response_geo, {"data": "geo_feed_data"}) + mock_get.assert_called_once_with(f'https://api.waqi.info/feed/geo:{lat};{lon}/?token={self.api_key}') + @patch.object(requests, 'get') def test_map_stations_api(self, mock_get): mock_response = Mock() @@ -71,6 +108,14 @@ def test_map_stations_api(self, mock_get): self.assertEqual(response_map_stations, '{"data": "map_stations_data"}') mock_get.assert_called_once_with(f'https://api.waqi.info/map/bounds/?token={self.api_key}&latlng=40.712,-74.006,34.052,-118.243') + @patch.object(requests, 'get') + def test_map_stations_api_json(self, mock_get): + mock_response = Mock() + mock_response.json.return_value = {"data": "map_stations_data"} + mock_get.return_value = mock_response + map_stations_api = self.factory.map_station() -if __name__ == '__main__': - unittest.main() + response_map_stations = map_stations_api.set_map_bounds(40.712, -74.006, 34.052, -118.243).fetch(as_json=True) + + self.assertEqual(response_map_stations, {"data": "map_stations_data"}) + mock_get.assert_called_once_with(f'https://api.waqi.info/map/bounds/?token={self.api_key}&latlng=40.712,-74.006,34.052,-118.243')