the-datagarden 0.1.0__py3-none-any.whl → 1.2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ from .api.authentication.environment import TheDatagardenLocalEnvironment
2
+ from .api.base import TheDataGardenAPI, TheDatagardenProductionEnvironment
3
+
4
+ __all__ = [
5
+ "TheDataGardenAPI",
6
+ "TheDatagardenProductionEnvironment",
7
+ "TheDatagardenLocalEnvironment",
8
+ ]
@@ -0,0 +1,3 @@
1
+ from .authentication import BaseDataGardenCredentials, DatagardenEnvironment
2
+
3
+ __all__ = ["DatagardenEnvironment", "BaseDataGardenCredentials"]
@@ -0,0 +1,19 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from requests import Response
4
+
5
+ from the_datagarden.abc.authentication import DatagardenEnvironment
6
+
7
+
8
+ class BaseApi(ABC):
9
+ @abstractmethod
10
+ def __init__(self, environment: type[DatagardenEnvironment] | None = None): ...
11
+
12
+ @abstractmethod
13
+ def retrieve_from_api(
14
+ self,
15
+ url_extension: str,
16
+ method: str = "GET",
17
+ payload: dict | None = None,
18
+ params: dict | None = None,
19
+ ) -> Response | None: ...
@@ -0,0 +1,42 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TypedDict
3
+
4
+
5
+ class TheDatagardenCredentialsDict(TypedDict):
6
+ email: str
7
+ password: str
8
+
9
+
10
+ class BaseDataGardenCredentials(ABC):
11
+ """Protocol for the datagarden credentials"""
12
+
13
+ @classmethod
14
+ @abstractmethod
15
+ def credentials(
16
+ cls, the_datagarden_api_url: str, email: str | None = None, password: str | None = None
17
+ ) -> TheDatagardenCredentialsDict: ...
18
+
19
+
20
+ class DatagardenEnvironment(ABC):
21
+ """Protocol for the datagarden environment"""
22
+
23
+ CREDENTIALS: type[BaseDataGardenCredentials]
24
+ THE_DATAGARDEN_URL: str
25
+ ECHO_INIT: bool = True
26
+
27
+ def __init__(self):
28
+ if self.ECHO_INIT:
29
+ print("Initializing :", self.__class__.__name__)
30
+ print("At :", self.the_datagarden_url)
31
+
32
+ def credentials(
33
+ self, email: str | None = None, password: str | None = None
34
+ ) -> TheDatagardenCredentialsDict:
35
+ return self.CREDENTIALS.credentials(self.the_datagarden_url, email, password)
36
+
37
+ @property
38
+ def the_datagarden_url(self) -> str:
39
+ url = self.THE_DATAGARDEN_URL
40
+ if url[-1] != "/":
41
+ return url + "/"
42
+ return url
@@ -0,0 +1,5 @@
1
+ from .authentication import AccessToken
2
+ from .authentication.environment import TheDatagardenProductionEnvironment
3
+ from .base import TheDataGardenAPI
4
+
5
+ __all__ = ["AccessToken", "TheDatagardenProductionEnvironment", "TheDataGardenAPI"]
@@ -0,0 +1,112 @@
1
+ import json
2
+ from datetime import UTC, datetime, timedelta
3
+
4
+ import jwt
5
+ import requests
6
+ from requests import Response
7
+
8
+ from ...abc.authentication import DatagardenEnvironment
9
+ from .settings import (
10
+ BEARER_KEY,
11
+ DEFAULT_HEADER,
12
+ REFRESH_TOKEN_URL_EXTENSION,
13
+ REQ_TOKEN_URL_EXTENSION,
14
+ )
15
+
16
+
17
+ class AccessToken:
18
+ _instance = None
19
+ _tokens: dict = {}
20
+ TOKEN_LIFE_TIME_MARGIN: int = 20
21
+ ACCESS_TOKEN_KEY = "access"
22
+ REFRESH_TOKEN_KEY = "refresh"
23
+
24
+ def __new__(cls, *args, **kwargs):
25
+ if cls._instance is None:
26
+ cls._instance = super().__new__(cls)
27
+ return cls._instance
28
+
29
+ def __init__(
30
+ self,
31
+ environment: type[DatagardenEnvironment],
32
+ email: str | None = None,
33
+ password: str | None = None,
34
+ ) -> None:
35
+ self._environment = environment()
36
+ self._token_payload = self._environment.credentials(email, password)
37
+ self._token_header = DEFAULT_HEADER.copy()
38
+
39
+ @property
40
+ def _token_url(self) -> str:
41
+ return self._the_datagarden_url + REQ_TOKEN_URL_EXTENSION
42
+
43
+ @property
44
+ def _refresh_token_url(self) -> str:
45
+ return self._the_datagarden_url + REFRESH_TOKEN_URL_EXTENSION
46
+
47
+ @property
48
+ def _the_datagarden_url(self) -> str:
49
+ return self._environment.the_datagarden_url
50
+
51
+ def _access_token_expired(self) -> bool:
52
+ if self._token_expiry_time:
53
+ return datetime.now(tz=UTC) + timedelta(seconds=5) > self._token_expiry_time
54
+ return True
55
+
56
+ @property
57
+ def _access_token(self) -> str:
58
+ if not self._tokens:
59
+ self._request_tokens()
60
+ elif self._access_token_expired():
61
+ self._get_refresh_token()
62
+ return self._tokens.get(self.ACCESS_TOKEN_KEY, "")
63
+
64
+ @property
65
+ def header_with_access_token(self) -> dict[str, str]:
66
+ header = DEFAULT_HEADER.copy()
67
+ header["Authorization"] = BEARER_KEY + self._access_token
68
+ return header
69
+
70
+ def _request_tokens(self):
71
+ response = requests.request(
72
+ method="POST",
73
+ url=self._token_url,
74
+ headers=self._token_header,
75
+ data=json.dumps(self._token_payload),
76
+ )
77
+ if not response.status_code == 200:
78
+ print("Token request failed and returned error(s): ")
79
+ print(" Errorr : ", response.json().get("detail", "No error details provided"))
80
+ quit()
81
+
82
+ self._tokens = self._get_response_data(response)
83
+ self._set_token_expiry_time()
84
+
85
+ def _get_refresh_token(self):
86
+ response = requests.request(
87
+ method="POST",
88
+ url=self._refresh_token_url,
89
+ headers=self._token_header,
90
+ data=json.dumps(self._refresh_payload()),
91
+ )
92
+ if not response.status_code == 200:
93
+ raise ValueError("Token request failed and returned error: " f"{response.text}")
94
+ self._tokens = self._get_response_data(response)
95
+ self._set_token_expiry_time()
96
+
97
+ def _refresh_payload(self) -> dict:
98
+ refresh_token = self._tokens.get(self.REFRESH_TOKEN_KEY, "")
99
+ return {"refresh": refresh_token}
100
+
101
+ def _set_token_expiry_time(self):
102
+ if not self._tokens:
103
+ return
104
+ access_token = self._tokens.get(self.ACCESS_TOKEN_KEY, "")
105
+ if not access_token:
106
+ raise ValueError("Access token not found in response")
107
+ decoded_token = jwt.decode(access_token, options={"verify_signature": False})
108
+ exp_time_stamp = decoded_token["exp"]
109
+ self._token_expiry_time = datetime.fromtimestamp(timestamp=exp_time_stamp, tz=UTC)
110
+
111
+ def _get_response_data(self, response: Response) -> dict[str, str]:
112
+ return json.loads(response.text)
@@ -0,0 +1,120 @@
1
+ import requests
2
+ from decouple import config
3
+
4
+ from the_datagarden.abc.authentication import BaseDataGardenCredentials, TheDatagardenCredentialsDict
5
+ from the_datagarden.api.authentication.settings import REGISTRATION_URL_EXTENSION
6
+
7
+
8
+ class CredentialsFromUserInput:
9
+ def get_missing_credentials(self, the_datagarden_api_url: str) -> TheDatagardenCredentialsDict:
10
+ print()
11
+ print("Welcome to The Data Garden API.")
12
+ print()
13
+ print(" You can start using the API with an account from The-Datagarden.io.")
14
+ print(" Please provide your credentials or create a new account.")
15
+ print(" Check www.the-datagarden.io for more information.")
16
+ print()
17
+ choice = input(
18
+ "Do you want to (1) create a new account or (2) provide existing credentials? " "Enter 1 or 2: "
19
+ )
20
+
21
+ if choice == "1":
22
+ credentials = self.enroll_to_api(the_datagarden_api_url)
23
+ if not credentials:
24
+ quit()
25
+ return credentials
26
+
27
+ elif choice == "2":
28
+ return self.provide_existing_credentials()
29
+ else:
30
+ print("Invalid choice. Please enter 1 or 2.")
31
+ return self.get_missing_credentials(the_datagarden_api_url)
32
+
33
+ def enroll_to_api(self, the_datagarden_api_url: str) -> TheDatagardenCredentialsDict | None:
34
+ print("Enrolling in The Data Garden API...")
35
+ print()
36
+ email = input(" Enter your email: ")
37
+ password = self.get_confirmed_password()
38
+ print()
39
+
40
+ data = {"email": email, "password": password}
41
+ registration_url = the_datagarden_api_url + REGISTRATION_URL_EXTENSION
42
+ response = requests.post(registration_url, data=data)
43
+ if response.status_code == 201:
44
+ print("Successfully enrolled in The Data Garden API.")
45
+ return TheDatagardenCredentialsDict(
46
+ email=email,
47
+ password=password,
48
+ )
49
+ else:
50
+ print("Enrollment failed with error(s):")
51
+ for field, message_list in response.json().items():
52
+ print(f" {field}: {message_list[0]}")
53
+
54
+ return None
55
+
56
+ def get_confirmed_password(self) -> str:
57
+ while True:
58
+ password = input(" Enter your password: ")
59
+ confirm_password = input(" Confirm your password: ")
60
+
61
+ if password == confirm_password:
62
+ return password
63
+ else:
64
+ print("Passwords do not match. Please try again.")
65
+
66
+ def provide_existing_credentials(self) -> TheDatagardenCredentialsDict:
67
+ print("Please provide your existing credentials...")
68
+ print()
69
+ email = input(" Enter your email: ")
70
+ password = input(" Enter your password: ")
71
+ return TheDatagardenCredentialsDict(
72
+ email=email,
73
+ password=password,
74
+ )
75
+
76
+ def credentials(self, the_datagarden_api_url: str) -> TheDatagardenCredentialsDict:
77
+ return self.get_missing_credentials(the_datagarden_api_url)
78
+
79
+
80
+ class TheDataGardenCredentials(BaseDataGardenCredentials):
81
+ """
82
+ Manages credentials for The Data Garden API in a production environment.
83
+
84
+ This class handles the retrieval of user credentials (email and password) for
85
+ authenticating with The Data Garden API. It first attempts to fetch credentials
86
+ from environment variables. If not found, it prompts the user for input.
87
+
88
+ Attributes:
89
+ ENV_EMAIL_KEY (str): Environment variable key for the user's email.
90
+ ENV_PASSWORD_KEY (str): Environment variable key for the user's password.
91
+ CREDENTIALS_FROM_USER_INPUT (CredentialsFromUserInput):
92
+ Instance to handle user input for credentials.
93
+
94
+ Methods:
95
+ credentials(): Retrieves and returns the user's credentials.
96
+ """
97
+
98
+ ENV_EMAIL_KEY: str = "THE_DATAGARDEN_USER_EMAIL"
99
+ ENV_PASSWORD_KEY: str = "THE_DATAGARDEN_USER_PASSWORD"
100
+ CREDENTIALS_FROM_USER_INPUT = CredentialsFromUserInput()
101
+
102
+ @classmethod
103
+ def credentials(
104
+ cls, the_datagarden_api_url: str, email: str | None = None, password: str | None = None
105
+ ) -> TheDatagardenCredentialsDict:
106
+ if email and password:
107
+ return TheDatagardenCredentialsDict(
108
+ email=email,
109
+ password=password,
110
+ )
111
+
112
+ datagarden_user_email = str(config(cls.ENV_EMAIL_KEY, default="", cast=str))
113
+ datagarden_user_password = str(config(cls.ENV_PASSWORD_KEY, default="", cast=str))
114
+ if datagarden_user_email and datagarden_user_password:
115
+ return TheDatagardenCredentialsDict(
116
+ email=datagarden_user_email,
117
+ password=datagarden_user_password,
118
+ )
119
+
120
+ return cls.CREDENTIALS_FROM_USER_INPUT.credentials(the_datagarden_api_url)
@@ -0,0 +1,13 @@
1
+ from the_datagarden.abc import DatagardenEnvironment
2
+
3
+ from ..credentials import TheDataGardenCredentials
4
+
5
+
6
+ class TheDatagardenProductionEnvironment(DatagardenEnvironment):
7
+ CREDENTIALS = TheDataGardenCredentials
8
+ THE_DATAGARDEN_URL = "https://api.the-datagarden.io"
9
+
10
+
11
+ class TheDatagardenLocalEnvironment(DatagardenEnvironment):
12
+ CREDENTIALS = TheDataGardenCredentials
13
+ THE_DATAGARDEN_URL = "http://127.0.0.1:8000"
@@ -0,0 +1,54 @@
1
+ """
2
+ Authentication settings for The DataGarden API.
3
+
4
+ This module contains constants and default values used for authentication
5
+ and API communication with The DataGarden service.
6
+
7
+ Constants:
8
+ REQ_TOKEN_URL_EXTENSION (str): URL extension for requesting a token.
9
+ REFRESH_TOKEN_URL_EXTENSION (str): URL extension for refreshing a token.
10
+ HTTPS_PREFIX (str): Prefix for HTTPS URLs.
11
+ BEARER_KEY (str): Prefix for Bearer token authentication.
12
+ API_EXTENSION (str): Extension for API endpoints (currently empty).
13
+ DEFAULT_HEADER (dict): Default headers for API requests.
14
+
15
+ """
16
+
17
+ from decouple import config
18
+
19
+ from the_datagarden.version import __version__
20
+
21
+ INCLUDE_STATISTIC_PARAM = {"include_statistics": "y"}
22
+
23
+ STATISTICS_URL_EXTENSION = "statistics/"
24
+
25
+ SHOW_REQ_DETAIL = config("SHOW_REQ_DETAIL", default=False, cast=bool)
26
+ REQ_TOKEN_URL_EXTENSION = "user/token/"
27
+ REFRESH_TOKEN_URL_EXTENSION = "user/token/refresh/"
28
+
29
+ REGISTRATION_URL_EXTENSION = "user/enroll/"
30
+
31
+ HTTPS_PREFIX = "https://"
32
+ BEARER_KEY = "Bearer "
33
+ API_EXTENSION = ""
34
+
35
+ DEFAULT_HEADER: dict[str, str] = {
36
+ "accept": "application/json",
37
+ "accept-language": "en,nl-NL;q=0.9,nl;q=0.8,sv;q=0.7",
38
+ "content-type": "application/json",
39
+ "X-The-Datagarden-Version": "The_datagarden_pypi_package_version__" + __version__,
40
+ }
41
+
42
+
43
+ class URLExtension:
44
+ WORLD = "world/"
45
+ WORLD_DATA = "world/regional_data/"
46
+ CONTINENTS = "continent/"
47
+ CONTINENT = "continent/"
48
+ COUNTRIES = "country/"
49
+ COUNTRY = "country/"
50
+
51
+
52
+ class DynamicEndpointCategories:
53
+ CONTINENTS = "continents"
54
+ COUNTRIES = "countries"
@@ -0,0 +1,215 @@
1
+ """
2
+ Base module for The Data Garden API.
3
+
4
+ This module provides the foundation for interacting with The Data Garden API.
5
+ It includes the base class for API interactions and imports necessary
6
+ authentication components.
7
+
8
+ Classes:
9
+ TheDataGardenAPI: Base class for interacting with The Data Garden API.
10
+
11
+ Imports:
12
+ DatagardenEnvironment: Abstract base class for environment authentication.
13
+ TheDatagardenProductionEnvironment: Concrete implementation of the production
14
+ environment.
15
+ URLExtension: Class for handling URL extensions.
16
+ """
17
+
18
+ from collections import defaultdict
19
+ from typing import Iterator
20
+
21
+ import requests
22
+ from requests import Response
23
+
24
+ from the_datagarden.abc.api import BaseApi
25
+ from the_datagarden.abc.authentication import DatagardenEnvironment
26
+ from the_datagarden.api.authentication import AccessToken
27
+ from the_datagarden.api.authentication.environment import TheDatagardenProductionEnvironment
28
+ from the_datagarden.api.authentication.settings import (
29
+ SHOW_REQ_DETAIL,
30
+ DynamicEndpointCategories,
31
+ URLExtension,
32
+ )
33
+ from the_datagarden.api.regions import Continent
34
+ from the_datagarden.api.regions.country import Country
35
+
36
+
37
+ class BaseDataGardenAPI(BaseApi):
38
+ """
39
+ Base class for interacting with The Data Garden API.
40
+ """
41
+
42
+ ACCESS_TOKEN: type[AccessToken] = AccessToken
43
+ DYNAMIC_ENDPOINTS: dict = defaultdict(dict)
44
+
45
+ def __init__(
46
+ self,
47
+ environment: type[DatagardenEnvironment] | None = None,
48
+ email: str | None = None,
49
+ password: str | None = None,
50
+ ):
51
+ self._environment = environment or TheDatagardenProductionEnvironment
52
+ self._tokens = self.ACCESS_TOKEN(self._environment, email, password)
53
+ self._base_url = self._environment().the_datagarden_url
54
+
55
+ def _generate_url(self, url_extension: str) -> str:
56
+ url = self._base_url + url_extension
57
+ if url[-1] != "/":
58
+ url += "/"
59
+ return url
60
+
61
+ def retrieve_from_api(
62
+ self,
63
+ url_extension: str,
64
+ method: str = "GET",
65
+ payload: dict | None = None,
66
+ params: dict | None = None,
67
+ ) -> Response | None:
68
+ url = self._generate_url(url_extension)
69
+ headers = self._tokens.header_with_access_token
70
+ if SHOW_REQ_DETAIL:
71
+ print(f"Request URL: {url}")
72
+ print(f"Request headers: {headers}")
73
+ print(f"Request method: {method}")
74
+ print(f"Request payload: {payload}")
75
+ print(f"Request params: {params}")
76
+ match method:
77
+ case "GET":
78
+ response = requests.get(url, params=params, headers=headers)
79
+ case "POST":
80
+ response = requests.post(url, json=payload, headers=headers)
81
+ case _:
82
+ raise ValueError(f"Invalid method: {method}")
83
+
84
+ return self._response_handler(response)
85
+
86
+ def _response_handler(self, response: requests.Response) -> Response | None:
87
+ if response.status_code == 200:
88
+ return response
89
+ else:
90
+ response_dict = response.json()
91
+ for k, v in response_dict.items():
92
+ print(k)
93
+ print(v)
94
+ return None
95
+
96
+ def _get_next_page(self, response: requests.Response) -> requests.Response | None:
97
+ next_url = response.json().get("next")
98
+ if not next_url:
99
+ return None
100
+
101
+ # Determine the original request method
102
+ original_method = response.request.method
103
+
104
+ headers = self._tokens.header_with_access_token
105
+
106
+ if original_method == "GET":
107
+ return requests.get(next_url, headers=headers)
108
+ elif original_method == "POST":
109
+ # For POST requests, we need to preserve the original payload
110
+ original_payload = response.request.body
111
+ return requests.post(next_url, data=original_payload, headers=headers)
112
+ else:
113
+ raise ValueError(f"Unsupported method for pagination: {original_method}")
114
+
115
+ def _records_from_paginated_api_response(self, response: requests.Response | None) -> Iterator[dict]:
116
+ while response:
117
+ for record in response.json()["results"]:
118
+ yield record
119
+ response = self._get_next_page(response)
120
+
121
+ def _create_url_extension(self, url_extensions: list[str]) -> str:
122
+ url = "/".join(url_extensions).lower().replace(" ", "-")
123
+ if url_extensions[-1] == "/":
124
+ return url
125
+ return url + "/"
126
+
127
+
128
+ class TheDataGardenAPI(BaseDataGardenAPI):
129
+ _instance = None
130
+ _initialized = False
131
+
132
+ def __new__(cls, *args, **kwargs):
133
+ if cls._instance is None:
134
+ cls._instance = super().__new__(cls)
135
+ return cls._instance
136
+
137
+ def __init__(
138
+ self,
139
+ environment: type[DatagardenEnvironment] | None = None,
140
+ email: str | None = None,
141
+ password: str | None = None,
142
+ ):
143
+ if not self._initialized:
144
+ super().__init__(environment, email, password)
145
+ self._setup_continents()
146
+ self._setup_countries()
147
+ self.__class__._initialized = True
148
+
149
+ def __getattr__(self, attr: str):
150
+ for _, endpoints in self.DYNAMIC_ENDPOINTS.items():
151
+ if attr.lower() in endpoints:
152
+ return endpoints[attr.lower()]
153
+
154
+ raise AttributeError(f"'{self.__class__.__name__}' object has no attribute '{attr}'")
155
+
156
+ def world(self):
157
+ response = self.retrieve_from_api(URLExtension.WORLD)
158
+ return response.json()
159
+
160
+ def continents(self, include_details: bool = False) -> list[str] | dict:
161
+ continents = (
162
+ self.DYNAMIC_ENDPOINTS.get(DynamicEndpointCategories.CONTINENTS, None) or self._setup_continents()
163
+ )
164
+ if not include_details:
165
+ return continents.keys()
166
+ return continents
167
+
168
+ def countries(self, include_details: bool = False) -> list[str] | dict:
169
+ countries = (
170
+ self.DYNAMIC_ENDPOINTS.get(DynamicEndpointCategories.COUNTRIES, None) or self._setup_countries()
171
+ )
172
+ if not include_details:
173
+ return countries.keys()
174
+ return countries
175
+
176
+ def _setup_continents(self):
177
+ if not self.DYNAMIC_ENDPOINTS.get(DynamicEndpointCategories.CONTINENTS, None):
178
+ continents = self.retrieve_from_api(URLExtension.CONTINENTS)
179
+ for continent in self._records_from_paginated_api_response(continents):
180
+ continent_method_name = continent["name"].lower().replace(" ", "_")
181
+ self.DYNAMIC_ENDPOINTS[DynamicEndpointCategories.CONTINENTS].update(
182
+ {
183
+ continent_method_name: Continent(
184
+ url=self._create_url_extension([URLExtension.CONTINENT + continent["name"]]),
185
+ api=self,
186
+ name=continent["name"].lower(),
187
+ ),
188
+ }
189
+ )
190
+
191
+ return self.DYNAMIC_ENDPOINTS[DynamicEndpointCategories.CONTINENTS]
192
+
193
+ def _setup_countries(self):
194
+ if not self.DYNAMIC_ENDPOINTS.get(DynamicEndpointCategories.COUNTRIES, None):
195
+ countries = self.retrieve_from_api(URLExtension.COUNTRIES)
196
+ if not countries:
197
+ return None
198
+ for country in self._records_from_paginated_api_response(countries):
199
+ country_method_name = country["name"].lower().replace(" ", "_")
200
+ country_code = country["iso_cc_2"].lower()
201
+ continent = country["parent_region"].lower()
202
+ country = Country(
203
+ url=self._create_url_extension([URLExtension.COUNTRY + country["name"]]),
204
+ api=self,
205
+ name=country["name"],
206
+ continent=continent,
207
+ )
208
+ self.DYNAMIC_ENDPOINTS[DynamicEndpointCategories.COUNTRIES].update(
209
+ {
210
+ country_method_name: country,
211
+ country_code: country,
212
+ }
213
+ )
214
+
215
+ return self.DYNAMIC_ENDPOINTS[DynamicEndpointCategories.COUNTRIES]
@@ -0,0 +1,4 @@
1
+ from .continent import Continent
2
+ from .country import Country
3
+
4
+ __all__ = ["Continent", "Country"]