jstdata 0.1.0__tar.gz

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.

Potentially problematic release.


This version of jstdata might be problematic. Click here for more details.

jstdata-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahfuj Ahbab
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT of OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
jstdata-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: jstdata
3
+ Version: 0.1.0
4
+ Summary: A Python interface to the Jefferson Street REST API.
5
+ License: MIT
6
+ License-File: LICENSE
7
+ Keywords: api,cli,data,jefferson street
8
+ Author: Mahfuj Ahbab
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
20
+ Requires-Dist: click (>=8.2.1,<9.0.0)
21
+ Requires-Dist: pandas (>=2.3.1,<3.0.0)
22
+ Requires-Dist: tabulate (>=0.9.0,<0.10.0)
23
+ Project-URL: Homepage, https://github.com/mahfuj/python_api
24
+ Project-URL: Repository, https://github.com/mahfuj/python_api
25
+ Description-Content-Type: text/markdown
26
+
27
+ # jstdata
28
+
29
+ A Python interface to the Jefferson Street REST API.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ pip install jstdata
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ Set your API key as an environment variable:
40
+
41
+ ```bash
42
+ export JEFFERSON_STREET_API_KEY="your_api_key_here"
43
+ ```
44
+
45
+ Then you can use the CLI:
46
+
47
+ ```bash
48
+ jst --help
49
+ ```
50
+
51
+ Or use it as a Python library:
52
+
53
+ ```python
54
+ from jstdata.client import JeffersonStreetClient
55
+
56
+ client = JeffersonStreetClient("your_api_key_here")
57
+ metrics = client.get_metrics()
58
+ print(metrics)
59
+ ```
60
+
@@ -0,0 +1,33 @@
1
+ # jstdata
2
+
3
+ A Python interface to the Jefferson Street REST API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install jstdata
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ Set your API key as an environment variable:
14
+
15
+ ```bash
16
+ export JEFFERSON_STREET_API_KEY="your_api_key_here"
17
+ ```
18
+
19
+ Then you can use the CLI:
20
+
21
+ ```bash
22
+ jst --help
23
+ ```
24
+
25
+ Or use it as a Python library:
26
+
27
+ ```python
28
+ from jstdata.client import JeffersonStreetClient
29
+
30
+ client = JeffersonStreetClient("your_api_key_here")
31
+ metrics = client.get_metrics()
32
+ print(metrics)
33
+ ```
@@ -0,0 +1 @@
1
+ from .client import JeffersonStreetClient
@@ -0,0 +1,213 @@
1
+ import os
2
+ import sys
3
+
4
+ import click
5
+
6
+ from .client import ApiKeyNotSetError, JeffersonStreetClient
7
+ from .utils import common_params, format_and_print
8
+
9
+ api_key = os.getenv("JEFFERSON_STREET_API_KEY")
10
+ if api_key is None:
11
+ raise click.Abort(
12
+ "Please set the JEFFERSON_STREET_API_KEY environment variable to a valid API key."
13
+ )
14
+
15
+ client = JeffersonStreetClient(api_key)
16
+
17
+
18
+ @click.group()
19
+ def cli():
20
+ """
21
+ Jefferson Street CLI.
22
+ """
23
+
24
+
25
+ @cli.group()
26
+ def metric():
27
+ """
28
+ Commands for interacting with metrics.
29
+ """
30
+
31
+
32
+ @cli.group()
33
+ def entity():
34
+ """
35
+ Commands for interacting with entities.
36
+ """
37
+
38
+
39
+ @cli.group()
40
+ def query():
41
+ """
42
+ Commands for querying data.
43
+ """
44
+
45
+
46
+ @entity.command("groups")
47
+ @click.option(
48
+ "--format",
49
+ default="pretty",
50
+ help="Output format. Valid formats are: json, csv, pretty.",
51
+ )
52
+ def list_entity_groups(format):
53
+ """
54
+ List all available entity groups.
55
+ """
56
+ entity_groups = client.get_entity_groups()
57
+ format_and_print(entity_groups, format)
58
+
59
+
60
+ @entity.command("values")
61
+ @click.argument("entity_group", required=True)
62
+ @common_params
63
+ def list_entities(entity_group, limit, format, offset):
64
+ """
65
+ List entities within a specified entity group.
66
+ """
67
+ results = client.get_entities(entity_group, offset, limit)
68
+ format_and_print(results, format)
69
+
70
+
71
+ @entity.command("metrics")
72
+ @click.argument("entity", required=True)
73
+ @common_params
74
+ def get_entity_links(entity, limit, format, offset):
75
+ """
76
+ Retrieve metrics associated with a specific entity.
77
+ """
78
+ results = client.get_entity_metrics(entity, limit, offset)
79
+ format_and_print(results, format)
80
+
81
+
82
+ @metric.command("ls")
83
+ @common_params
84
+ @click.option(
85
+ "--sort_order", default="desc", help="Sort order (asc or desc, default: desc)"
86
+ )
87
+ @click.option(
88
+ "--order_by",
89
+ default="last_updated",
90
+ help="Column to order by (default: last_updated)",
91
+ )
92
+ @click.option("--expanded", is_flag=True, default=False, help="Return expanded metrics")
93
+ def list_metrics(limit, offset, sort_order, order_by, format, expanded):
94
+ """
95
+ List all available metrics.
96
+ """
97
+ metrics = client.get_metrics(None, limit, offset, order_by, sort_order)
98
+ if expanded:
99
+ format_and_print(metrics, format)
100
+ else:
101
+ condensed_metrics = [{"slug": m["slug"]} for m in metrics]
102
+ format_and_print(condensed_metrics, format)
103
+
104
+
105
+ @metric.command("show")
106
+ @click.argument("metric", required=True)
107
+ @click.option(
108
+ "--format",
109
+ default="pretty",
110
+ help="Output format. Valid formats are: json, csv, pretty.",
111
+ )
112
+ def show_metric(metric, format):
113
+ """
114
+ Display details for a specific metric.
115
+ """
116
+ metric = client.get_metrics(metric)
117
+ format_and_print(metric, format)
118
+
119
+
120
+ @metric.command("entities")
121
+ @click.argument("metric", required=True)
122
+ @click.option(
123
+ "--format",
124
+ default="pretty",
125
+ help="Output format. Valid formats are: json, csv, pretty.",
126
+ )
127
+ def show_metric_dimensions(metric, format):
128
+ """
129
+ Display dimensions for a specific metric.
130
+ """
131
+ dimensions = client.get_metric_dimensions(metric)
132
+ format_and_print(dimensions, format)
133
+
134
+
135
+ @query.command("metric")
136
+ @common_params
137
+ @click.argument("metric", required=True, nargs=1)
138
+ @click.option(
139
+ "--sort_order", default="desc", help="Sort order (asc or desc, default: desc)"
140
+ )
141
+ @click.option("--order_by", default="id", help="Column to order by (default: id)")
142
+ @click.option(
143
+ "--start_date",
144
+ default=None,
145
+ help="Start period. Valid formats: YYYY-MM-DD, YYYY-MM, YYYY, unix timestamp",
146
+ )
147
+ @click.option(
148
+ "--end_date",
149
+ default=None,
150
+ help="End period. Valid formats: YYYY-MM-DD, YYYY-MM, YYYY, unix timestamp",
151
+ )
152
+ def get_observations_by_metric(
153
+ metric, sort_order, order_by, start_date, end_date, limit, offset, format
154
+ ):
155
+ """
156
+ Retrieve observations for a specific metric.
157
+ """
158
+ observations = client.query("metric", metric, start_date, end_date, limit, offset)
159
+ format_and_print(observations, format)
160
+
161
+
162
+ @query.command("entity")
163
+ @click.argument("entity", required=True, nargs=1)
164
+ @click.option(
165
+ "--sort_order", default="desc", help="Sort order (asc or desc, default: desc)"
166
+ )
167
+ @click.option("--order_by", default="id", help="Column to order by (default: id)")
168
+ @click.option(
169
+ "--start_date",
170
+ default=None,
171
+ help="Start period. Valid formats: YYYY-MM-DD, YYYY-MM, YYYY, unix timestamp",
172
+ )
173
+ @click.option(
174
+ "--end_date",
175
+ default=None,
176
+ help="End period. Valid formats: YYYY-MM-DD, YYYY-MM, YYYY, unix timestamp",
177
+ )
178
+ @common_params
179
+ def get_observations_by_entity(
180
+ entity, sort_order, order_by, start_date, end_date, limit, offset, format
181
+ ):
182
+ """
183
+ Retrieve observations for a specific entity.
184
+ """
185
+ observations = client.query("entity", entity, start_date, end_date, limit, offset)
186
+ format_and_print(observations, format)
187
+
188
+
189
+ @entity.command("search")
190
+ @click.option(
191
+ "--limit", default=3, help="Maximum number of records to return (default: 10000)"
192
+ )
193
+ @click.option("--offset", default=0, help="Number of records to skip (default: 0)")
194
+ @click.option(
195
+ "--format",
196
+ default="pretty",
197
+ help="Output format. Valid formats are: json, csv, pretty.",
198
+ )
199
+ @click.argument("query", required=True, nargs=1)
200
+ def search_for_entity(query, limit, offset, format):
201
+ """
202
+ Search for entities.
203
+ """
204
+ results = client.search_for_entity(query, limit, offset)
205
+ format_and_print(results, format)
206
+
207
+
208
+ if __name__ == "__main__":
209
+ try:
210
+ cli()
211
+ except ApiKeyNotSetError as e:
212
+ click.echo(f"Error: {e}", err=True)
213
+ sys.exit(1)
@@ -0,0 +1,258 @@
1
+ import os
2
+ from typing import Any, Dict, Optional
3
+ import requests
4
+ from collections import namedtuple
5
+
6
+ EntityType = namedtuple("EntityType", ["name", "slug", "classification"])
7
+
8
+
9
+ class ApiKeyNotSetError(Exception):
10
+ pass
11
+
12
+
13
+ class InvalidApiKeyError(Exception):
14
+ pass
15
+
16
+
17
+ class InvalidInputError(Exception):
18
+ pass
19
+
20
+
21
+ class JeffersonStreetClient:
22
+ # For testing purposes
23
+ base_url = os.getenv("JEFFERSON_STREET_SERVER") or "https://api.jeffersonst.io"
24
+
25
+ def __init__(self, api_key: Optional[str] = None):
26
+ """
27
+ Initializes the JeffersonStreetClient with an API key.
28
+
29
+ Args:
30
+ api_key: The API key for authenticating with the Jefferson Street REST API.
31
+ """
32
+ if api_key is None:
33
+ raise ApiKeyNotSetError("API key is not set")
34
+ self._api_key = api_key
35
+ self.session = requests.Session()
36
+ self.session.params = {"api-key": api_key}
37
+
38
+ self._api_key_is_valid = False
39
+
40
+ @property
41
+ def api_key(self):
42
+ """
43
+ Returns the API key used by the client.
44
+ """
45
+ return self._api_key
46
+
47
+ def _validate_api_key(self, api_key) -> None:
48
+ """
49
+ Validates the provided API key by making a heartbeat request to the API.
50
+
51
+ Args:
52
+ api_key: The API key to validate.
53
+
54
+ Raises:
55
+ ApiKeyNotSetError: If the API key is None.
56
+ InvalidApiKeyError: If the API key is invalid.
57
+ """
58
+ if api_key is None:
59
+ raise ApiKeyNotSetError("API key is not set")
60
+
61
+ url = f"{self.base_url}/heartbeat"
62
+ heartbeat = self.session.get(url).json()
63
+ if heartbeat["status"] != "ok":
64
+ raise InvalidApiKeyError("Invalid API key")
65
+ self._api_key_is_valid = True
66
+ return
67
+
68
+ def _make_request(
69
+ self, endpoint: str, params: Optional[Dict[str, Any]] = None
70
+ ) -> Dict[str, Any]:
71
+ """
72
+ Makes a GET request to the specified API endpoint.
73
+
74
+ Args:
75
+ endpoint: The API endpoint to call.
76
+ params: A dictionary of query parameters to send with the request.
77
+
78
+ Returns:
79
+ A dictionary containing the JSON response from the API.
80
+
81
+ Raises:
82
+ ApiKeyNotSetError: If the API key is not set.
83
+ InvalidApiKeyError: If the API key is invalid.
84
+ requests.exceptions.RequestException: For network-related errors or unsuccessful HTTP responses.
85
+ """
86
+ if not self._api_key_is_valid:
87
+ self._validate_api_key(self.api_key)
88
+
89
+ url = f"{self.base_url}/{endpoint}"
90
+ response = self.session.get(url, params=params)
91
+ response.raise_for_status()
92
+ return response.json()
93
+
94
+ def get_metrics(
95
+ self,
96
+ metric: Optional[str] = None,
97
+ limit: int = 100,
98
+ offset: int = 0,
99
+ order_by: str = "last_updated",
100
+ sort_order: str = "desc",
101
+ ) -> Dict[str, Any]:
102
+ """Get available metrics.
103
+
104
+ Args:
105
+ metric: The metric's slug. If empty, all metrics are returned
106
+ limit: Maximum number of records to return (default: 10000)
107
+ offset: Number of records to skip (default: 0)
108
+ order_by: Column to order by (default: "last_updated")
109
+ sort_order: Sort order ("asc" or "desc", default: "desc")
110
+
111
+ Returns:
112
+ MetricResponse containing list of available metrics
113
+ """
114
+ response = self._make_request(
115
+ "metric",
116
+ {
117
+ "metric": metric,
118
+ "limit": limit,
119
+ "offset": offset,
120
+ "order_by": order_by,
121
+ "sort_order": sort_order,
122
+ },
123
+ )
124
+ return response["records"]
125
+
126
+ def get_metric_dimensions(self, metric: str) -> Dict[str, Any]:
127
+ try:
128
+ response = self._make_request(f"metric/{metric}/entities")
129
+ except requests.exceptions.HTTPError as e:
130
+ raise InvalidInputError(f"Invalid input: {e}")
131
+ return response["records"]
132
+
133
+ def get_entity_groups(self) -> Dict[str, Any]:
134
+ """Get available entity types.
135
+
136
+ Returns:
137
+ Dictionary containing list of available entity types
138
+ """
139
+ try:
140
+ response = self._make_request("entity/groups")
141
+ except requests.exceptions.HTTPError as e:
142
+ raise InvalidInputError(f"Invalid input: {e}")
143
+ return response["records"]
144
+
145
+ def get_entities(
146
+ self,
147
+ entity_group: Optional[str] = None,
148
+ offset: int = 0,
149
+ limit: int = 10000,
150
+ sort_order: str = "asc",
151
+ ) -> Dict[str, Any]:
152
+ """Get available entities.
153
+ Args:
154
+ offset: Number of records to skip (default: 0)
155
+ limit: Maximum number of records to return (default: 10000)
156
+ sort_order: Sort order ("asc" or "desc", default: "asc")
157
+
158
+ Returns:
159
+ Dictionary containing list of available entities
160
+ """
161
+ response = self._make_request(
162
+ f"entity/{entity_group}",
163
+ {"offset": offset, "limit": limit, "sort_order": sort_order},
164
+ )
165
+ return response["records"]
166
+
167
+ def get_entity_metrics(
168
+ self,
169
+ entity: Optional[str] = None,
170
+ limit: int = 10000,
171
+ offset: int = 0,
172
+ sort_order: str = "asc",
173
+ ) -> Dict[str, Any]:
174
+ """Get metrics for a specific entity.
175
+
176
+ Args:
177
+ entity: The entity's slug
178
+ limit: Maximum number of records to return (default: 10000)
179
+ offset: Number of records to skip (default: 0)
180
+ order_by: Column to order by (default: "last_updated")
181
+ sort_order: Sort order ("asc" or "desc", default: "asc")
182
+
183
+ Returns:
184
+ MetricResponse containing list of metrics for the entity
185
+ """
186
+ response = self._make_request(
187
+ f"entity/{entity}/metrics",
188
+ {"limit": limit, "offset": offset, "sort_order": sort_order},
189
+ )
190
+ return response["records"]
191
+
192
+ def query(
193
+ self,
194
+ by: str,
195
+ id: str,
196
+ start_date: str,
197
+ end_date: str,
198
+ limit: int = 100,
199
+ offset: int = 0,
200
+ order_by: str = "release_date",
201
+ sort_order: str = "asc",
202
+ entity_filter: Optional[list[str]] = None,
203
+ ) -> Dict[str, Any]:
204
+ """Query by metric.
205
+
206
+ Args:
207
+ by: The type of query to perform (either "metric" or "entity")
208
+ id: The metric or entity's id/slug
209
+ start_date: Start date (YYYY-MM-DD)
210
+ end_date: End date (YYYY-MM-DD)
211
+ limit: Maximum number of records to return (default: 100)
212
+ offset: Number of records to skip (default: 0)
213
+ order_by: Column to order by (either "release_date" or "series_label", default: "release_date")
214
+ sort_order: Sort order ("asc" or "desc", default: "desc")
215
+
216
+ Returns:
217
+ QueryResponse containing list of query results
218
+ """
219
+ if by not in ["metric", "entity"]:
220
+ raise InvalidInputError(f"Invalid input: {by}")
221
+ if order_by not in ["release_date", "series_label"]:
222
+ raise InvalidInputError(f"Invalid input: {order_by}")
223
+ if sort_order not in ["asc", "desc"]:
224
+ raise InvalidInputError(f"Invalid input: {sort_order}")
225
+ params = {
226
+ "start_date": start_date,
227
+ "end_date": end_date,
228
+ "limit": limit,
229
+ "offset": offset,
230
+ "sort_order": sort_order,
231
+ "order_by": order_by,
232
+ }
233
+ if entity_filter is not None:
234
+ params["entities"] = entity_filter
235
+ response = self._make_request(f"query/{by}/{id}", params)
236
+ return response["records"]
237
+
238
+ def search_for_entity(
239
+ self,
240
+ query: str,
241
+ limit: int = 3,
242
+ offset: int = 0,
243
+ ) -> Dict[str, Any]:
244
+ """Search for an entity.
245
+
246
+ Args:
247
+ query: The query to search for
248
+ offset: Number of records to skip (default: 0)
249
+ limit: Maximum number of records to return (default: 10000)
250
+ sort_order: Sort order ("asc" or "desc", default: "asc")
251
+
252
+ Returns:
253
+ SearchResponse containing list of search results
254
+ """
255
+ response = self._make_request(
256
+ f"search/entity", {"query": query, "offset": offset, "limit": limit}
257
+ )
258
+ return response["records"]
@@ -0,0 +1,58 @@
1
+ import json
2
+ from io import StringIO
3
+
4
+ import click
5
+ import pandas as pd
6
+ from tabulate import tabulate
7
+
8
+
9
+ def common_params(f):
10
+ """
11
+ Decorator to apply common CLI parameters: limit, offset, and format.
12
+ """
13
+ f = click.option(
14
+ "--limit",
15
+ default=100,
16
+ help="Maximum number of records to return (default: 10000)",
17
+ )(f)
18
+ f = click.option(
19
+ "--offset", default=0, help="Number of records to skip (default: 0)"
20
+ )(f)
21
+ f = click.option(
22
+ "--format",
23
+ default="pretty",
24
+ help="Output format. Valid formats are: json, csv, pretty.",
25
+ )(f)
26
+ return f
27
+
28
+
29
+ def date_handler(date_str):
30
+ if len(date_str) in (10, 11): # YYYY-MM-DD or unix timestamp
31
+ return date_str
32
+ elif len(date_str) == 4: # year
33
+ return f"{date_str}-01-01"
34
+ elif len(date_str) == 7: # year-month
35
+ return f"{date_str}-01"
36
+ else:
37
+ raise ValueError(f"Invalid date format: {date_str}")
38
+
39
+
40
+ def df_to_csv_string(df):
41
+ csv_buffer = StringIO()
42
+ df.to_csv(csv_buffer, index=False)
43
+ return csv_buffer.getvalue()
44
+
45
+
46
+ def format_and_print(response_data, format):
47
+ if format == "json":
48
+ click.echo(json.dumps(response_data))
49
+ elif format == "csv":
50
+ click.echo(df_to_csv_string(pd.DataFrame(response_data)))
51
+ elif format == "pretty":
52
+ if len(response_data) == 1:
53
+ response_data = [(k, v) for k, v in response_data[0].items()]
54
+ click.echo(
55
+ tabulate(response_data, headers=["key", "value"], tablefmt="pretty")
56
+ )
57
+ return
58
+ click.echo(tabulate(response_data, headers="keys", tablefmt="pretty"))
@@ -0,0 +1,41 @@
1
+ [tool.poetry]
2
+ name = "jstdata"
3
+ version = "0.1.0"
4
+ description = "A Python interface to the Jefferson Street REST API."
5
+ authors = ["Mahfuj Ahbab"]
6
+ readme = "README.md"
7
+ license = "MIT"
8
+ homepage = "https://github.com/mahfuj/python_api"
9
+ repository = "https://github.com/mahfuj/python_api"
10
+ keywords = ["api", "cli", "data", "jefferson street"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "License :: OSI Approved :: MIT License",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Topic :: Software Development :: Libraries :: Python Modules",
19
+ ]
20
+
21
+ [tool.poetry.dependencies]
22
+ python = "^3.10"
23
+ click = "^8.2.1"
24
+ pandas = "^2.3.1"
25
+ tabulate = "^0.9.0"
26
+
27
+ [tool.poetry.group.dev.dependencies]
28
+ pytest = "^8.3.5"
29
+ requests-mock = "^1.12.1"
30
+ pytest-cov = "^6.1.1"
31
+ black = "^25.1.0"
32
+ isort = "^6.0.1"
33
+ flake8 = "^7.2.0"
34
+ mypy = "^1.15.0"
35
+
36
+ [build-system]
37
+ requires = ["poetry-core"]
38
+ build-backend = "poetry.core.masonry.api"
39
+
40
+ [tool.poetry.scripts]
41
+ jst = "jstdata.cli:cli"