udata-hydra-csvapi 0.3.2.dev9__py3-none-any.whl → 0.3.2.dev10__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.
@@ -7,7 +7,8 @@ PAGE_SIZE_DEFAULT = 20
7
7
  PAGE_SIZE_MAX = 50
8
8
  BATCH_SIZE = 50000
9
9
  DOC_PATH = "/api/doc"
10
- ALLOW_AGGREGATION = [
10
+ ALLOW_AGGREGATION = false
11
+ ALLOW_AGGREGATION_EXCEPTIONS = [
11
12
  "dddddddd-7777-eeee-8888-ffffffffffff", # without indexes
12
13
  "aaaaaaaa-9999-bbbb-1010-cccccccccccc", # with indexes
13
14
  ] # list of resource_ids
@@ -0,0 +1,24 @@
1
+ from aiohttp import web
2
+
3
+
4
+ @web.middleware
5
+ async def cors_middleware(request, handler):
6
+ """
7
+ Middleware to handle CORS and the OPTIONS preflight.
8
+ """
9
+ if request.method == "OPTIONS":
10
+ # Handle cases of preflight OPTIONS requests (was managed by aiohttp_cors before)
11
+ response = web.Response(status=204)
12
+ else:
13
+ response = await handler(request)
14
+
15
+ # Add CORS headers to the response
16
+ # Using "*" ensures the Nginx cache remains consistent for all origins
17
+ response.headers["Access-Control-Allow-Origin"] = "*"
18
+ response.headers["Access-Control-Allow-Methods"] = "GET, OPTIONS"
19
+ response.headers["Access-Control-Allow-Headers"] = (
20
+ "Content-Type, Authorization, X-Requested-With"
21
+ )
22
+ response.headers["Access-Control-Expose-Headers"] = "*"
23
+
24
+ return response
@@ -1,5 +1,3 @@
1
- from datetime import datetime, timezone
2
-
3
1
  from aiohttp import web
4
2
  from aiohttp.web_request import Request
5
3
 
@@ -15,9 +13,10 @@ async def check_health(request: Request, url: str):
15
13
  "DB unavailable",
16
14
  "postgREST has not started yet",
17
15
  )
18
- start_time = request.app["start_time"]
19
- current_time = datetime.now(timezone.utc)
20
- uptime_seconds = (current_time - start_time).total_seconds()
21
16
  return web.json_response(
22
- {"status": "ok", "version": request.app["app_version"], "uptime_seconds": uptime_seconds}
17
+ {
18
+ "status": "ok",
19
+ "version": request.app["app_version"],
20
+ "uptime_since": request.app["started_at"].isoformat(),
21
+ }
23
22
  )
api_tabular/core/query.py CHANGED
@@ -1,3 +1,4 @@
1
+ import re
1
2
  from collections import defaultdict
2
3
 
3
4
  from api_tabular.core.utils import is_aggregation_allowed
@@ -14,6 +15,10 @@ def build_sql_query_string(
14
15
  aggregators = defaultdict(list)
15
16
  sorted = False
16
17
  for arg in request_arg:
18
+ if arg.startswith("or=("):
19
+ # top level "and=(...)" should not happen
20
+ sql_query.append(parse_operator(query=arg, operator="or", top_level=True))
21
+ continue
17
22
  _split = arg.split("=")
18
23
  # filters are expected to have the syntax `<column_name>__<operator>=<value>`
19
24
  if len(_split) == 2:
@@ -72,39 +77,52 @@ def get_column_and_operator(argument: str) -> tuple[str, str]:
72
77
  return column, normalized_comparator
73
78
 
74
79
 
75
- def add_filter(argument: str, value: str | None) -> tuple[str | None, bool]:
80
+ def add_filter(
81
+ argument: str,
82
+ value: str | None,
83
+ *,
84
+ in_operator: bool = False,
85
+ ) -> tuple[str | None, bool]:
76
86
  if argument in ["page", "page_size"]: # processed differently
87
+ if in_operator:
88
+ raise ValueError(f"Argument `{argument}` can't be set within an operator")
77
89
  return None, False
78
90
  if argument == "columns":
91
+ if in_operator:
92
+ raise ValueError(f"Argument `{argument}` can't be set within an operator")
79
93
  return f"select={value}", False
80
94
  if "__" in argument:
81
95
  column, normalized_comparator = get_column_and_operator(argument)
96
+ # when encapsulated in an OR statement, the syntax is `col.eq.val` instead of `col=eq.val`
97
+ op = "." if in_operator else "="
82
98
  if normalized_comparator == "sort":
99
+ if in_operator:
100
+ raise ValueError(f"Argument `{argument}` can't be set within an operator")
83
101
  return f"order={column}.{value}", True
84
102
  elif normalized_comparator == "exact":
85
- return f"{column}=eq.{value}", False
103
+ return f"{column}{op}eq.{value}", False
86
104
  elif normalized_comparator == "differs":
87
- return f"{column}=isdistinct.{value}", False
105
+ return f"{column}{op}isdistinct.{value}", False
88
106
  elif normalized_comparator == "isnull":
89
- return f"{column}=is.null", False
107
+ return f"{column}{op}is.null", False
90
108
  elif normalized_comparator == "isnotnull":
91
- return f"{column}=not.is.null", False
109
+ return f"{column}{op}not.is.null", False
92
110
  elif normalized_comparator == "contains":
93
- return f"{column}=ilike.*{value}*", False
111
+ return f"{column}{op}ilike.*{value}*", False
94
112
  elif normalized_comparator == "notcontains":
95
- return f"{column}=not.ilike.*{value}*", False
113
+ return f"{column}{op}not.ilike.*{value}*", False
96
114
  elif normalized_comparator == "in":
97
- return f"{column}=in.({value})", False
115
+ return f"{column}{op}in.({value})", False
98
116
  elif normalized_comparator == "notin":
99
- return f"{column}=not.in.({value})", False
117
+ return f"{column}{op}not.in.({value})", False
100
118
  elif normalized_comparator == "less":
101
- return f"{column}=lte.{value}", False
119
+ return f"{column}{op}lte.{value}", False
102
120
  elif normalized_comparator == "greater":
103
- return f"{column}=gte.{value}", False
121
+ return f"{column}{op}gte.{value}", False
104
122
  elif normalized_comparator == "strictly_less":
105
- return f"{column}=lt.{value}", False
123
+ return f"{column}{op}lt.{value}", False
106
124
  elif normalized_comparator == "strictly_greater":
107
- return f"{column}=gt.{value}", False
125
+ return f"{column}{op}gt.{value}", False
108
126
  raise ValueError(f"argument '{argument}={value}' could not be parsed")
109
127
 
110
128
 
@@ -118,6 +136,91 @@ def add_aggregator(argument: str, indexes: set | None) -> tuple[str, str]:
118
136
  raise ValueError(f"argument '{argument}' could not be parsed")
119
137
 
120
138
 
139
+ def split_top_level(s: str) -> list[str]:
140
+ # we can't .split(",") as there may be commas within the params (if nested)
141
+ # so we need a custom "split by ',' if ',' not within parentheses"
142
+ parts = []
143
+ current = ""
144
+ depth = 0
145
+ for char in s:
146
+ if char == "(":
147
+ depth += 1
148
+ current += char
149
+ elif char == ")":
150
+ depth -= 1
151
+ current += char
152
+ elif char == "," and depth == 0:
153
+ parts.append(current)
154
+ current = ""
155
+ else:
156
+ current += char
157
+ if current:
158
+ parts.append(current)
159
+ return parts
160
+
161
+
162
+ def find_arg_val(param: str) -> tuple[str, str]:
163
+ if param.count('"') not in {0, 2, 4}:
164
+ raise ValueError(f"argument '{param}' could not be parsed")
165
+ column_operator_pattern = r'^"[^"]*"__[a-z]+'
166
+ value_pattern = r'\."[^"]*"$'
167
+ if param.count('"') == 0:
168
+ # no quote, we expect col__op.val
169
+ _split = param.split(".")
170
+ if len(_split) != 2:
171
+ raise ValueError(f"argument '{param}' could not be parsed")
172
+ return tuple[str, str](_split) # ty: ignore[invalid-argument-type]
173
+ elif param.count('"') == 4:
174
+ # 4 quotes, we expect "col.umn"__op."val.ue"
175
+ col_op = re.findall(column_operator_pattern, param)
176
+ val = re.findall(value_pattern, param)
177
+ if len(col_op) != 1 or len(val) != 1:
178
+ raise ValueError(f"argument '{param}' could not be parsed")
179
+ # we drop the double quotes around the column as they will be added back in get_column_and_operator
180
+ # the dot is included in the value result, we pop it in the result
181
+ return col_op[0].replace('"', ""), val[0][1:]
182
+ else:
183
+ # we have any of "col.umn"__op.val, col__op."val.ue"
184
+ col_op = re.findall(column_operator_pattern, param)
185
+ val = re.findall(value_pattern, param)
186
+ if not col_op:
187
+ # case col__op."val.ue"
188
+ return param.split(".")[0], val[0][1:]
189
+ else:
190
+ # case "col.umn"__op.val
191
+ return col_op[0].replace('"', ""), param.split(".")[-1]
192
+
193
+
194
+ def parse_operator(query: str, operator: str, top_level: bool = False):
195
+ # only the top level operator has an "=", nested operators are "or(...)" and "and(...)"
196
+ if not query.endswith(")"):
197
+ raise ValueError(f"argument '{query}' could not be parsed")
198
+ postgrest_params = []
199
+ # we can safely assume that there will be one result for the regex
200
+ params = split_top_level(
201
+ re.findall(rf"^{operator}{'=' if top_level else ''}\((.*)\)$", query)[0]
202
+ )
203
+ for param in params:
204
+ if param.startswith(("and(", "or(")):
205
+ # recursively adding the nested conditions
206
+ postgrest_params.append(parse_operator(query=param, operator=param.split("(")[0]))
207
+ else:
208
+ if param.endswith(("__isnull", "__isnotnull")):
209
+ postgrest_params.append(
210
+ add_filter(
211
+ param.replace('"', ""),
212
+ None,
213
+ in_operator=True,
214
+ )[0]
215
+ )
216
+ else:
217
+ # if a special character is in the column name and/or value, the problematic item should be encapsulated in double quotes
218
+ # so we have 4 possibles cases: col__op.val, "col.umn"__op.val, col__op."val.ue" and "col.umn"__op."val.ue"
219
+ argument, value = find_arg_val(param)
220
+ postgrest_params.append(add_filter(argument, value, in_operator=True)[0])
221
+ return f"{operator}{'=' if top_level else ''}({','.join(postgrest_params)})"
222
+
223
+
121
224
  def raise_if_not_index(column_name: str, indexes: set | None) -> None:
122
225
  if indexes is None:
123
226
  return
@@ -188,6 +188,13 @@ def swagger_parameters(resource_columns: dict, resource_id: str) -> list:
188
188
  "style": "form",
189
189
  "explode": False,
190
190
  },
191
+ {
192
+ "name": "or",
193
+ "in": "query",
194
+ "description": "Encapsulates filters to be joined with the `OR` operator. Allows to build complex queries with nested `or(...)` and `and(...)` operators. Inner filters syntax must be `<column>.<operation>.<value>` (or `<column>.<operation>` for `is(not)null`), separated with commas.",
195
+ "required": False,
196
+ "schema": {"type": "string"},
197
+ },
191
198
  ]
192
199
  # expected python types are: string, float, int, bool, date, datetime, json
193
200
  # see cast for db here: https://github.com/datagouv/csv-detective/blob/master/csv_detective/output/dataframe.py
api_tabular/core/utils.py CHANGED
@@ -6,7 +6,7 @@ from api_tabular.core.error import QueryException
6
6
 
7
7
 
8
8
  def is_aggregation_allowed(resource_id: str) -> bool:
9
- return resource_id in config.ALLOW_AGGREGATION
9
+ return config.ALLOW_AGGREGATION or resource_id in config.ALLOW_AGGREGATION_EXCEPTIONS
10
10
 
11
11
 
12
12
  def process_total(res: Response | ClientResponse) -> int:
@@ -1,13 +1,13 @@
1
1
  import os
2
2
  from datetime import datetime, timezone
3
3
 
4
- import aiohttp_cors
5
4
  import sentry_sdk
6
5
  import yaml
7
6
  from aiohttp import ClientSession, web
8
7
  from aiohttp_swagger import setup_swagger
9
8
 
10
9
  from api_tabular import config
10
+ from api_tabular.core.cors import cors_middleware
11
11
  from api_tabular.core.data import stream_data
12
12
  from api_tabular.core.error import QueryException, handle_exception
13
13
  from api_tabular.core.health import check_health
@@ -99,7 +99,7 @@ async def get_health(request):
99
99
  async def app_factory():
100
100
  async def on_startup(app):
101
101
  app["csession"] = ClientSession()
102
- app["start_time"] = datetime.now(timezone.utc)
102
+ app["started_at"] = datetime.now(timezone.utc)
103
103
  app["app_version"] = await get_app_version()
104
104
 
105
105
  with open("metrics_swagger.yaml", "r") as f:
@@ -111,22 +111,11 @@ async def app_factory():
111
111
  async def on_cleanup(app):
112
112
  await app["csession"].close()
113
113
 
114
- app = web.Application()
114
+ app = web.Application(middlewares=[cors_middleware])
115
115
  app.add_routes(routes)
116
116
  app.on_startup.append(on_startup)
117
117
  app.on_cleanup.append(on_cleanup)
118
118
 
119
- cors = aiohttp_cors.setup(
120
- app,
121
- defaults={
122
- "*": aiohttp_cors.ResourceOptions(
123
- allow_credentials=True, expose_headers="*", allow_headers="*"
124
- )
125
- },
126
- )
127
- for route in list(app.router.routes()):
128
- cors.add(route)
129
-
130
119
  return app
131
120
 
132
121
 
@@ -1,13 +1,13 @@
1
1
  import os
2
2
  from datetime import datetime, timezone
3
3
 
4
- import aiohttp_cors
5
4
  import sentry_sdk
6
5
  import yaml
7
6
  from aiohttp import ClientSession, web
8
7
  from aiohttp_swagger import setup_swagger
9
8
 
10
9
  from api_tabular import config
10
+ from api_tabular.core.cors import cors_middleware
11
11
  from api_tabular.core.health import check_health
12
12
  from api_tabular.core.sentry import get_sentry_kwargs
13
13
  from api_tabular.core.swagger import build_swagger_file
@@ -149,13 +149,17 @@ async def get_health(request):
149
149
  @routes.get(r"/api/aggregation-exceptions/")
150
150
  async def get_aggregation_exceptions(request):
151
151
  """Return the list of resources for which aggregation queries are allowed"""
152
- return web.json_response(config.ALLOW_AGGREGATION)
152
+ body = {
153
+ "allowed": config.ALLOW_AGGREGATION,
154
+ "exceptions": config.ALLOW_AGGREGATION_EXCEPTIONS,
155
+ }
156
+ return web.json_response(body)
153
157
 
154
158
 
155
159
  async def app_factory():
156
160
  async def on_startup(app):
157
161
  app["csession"] = ClientSession()
158
- app["start_time"] = datetime.now(timezone.utc)
162
+ app["started_at"] = datetime.now(timezone.utc)
159
163
  app["app_version"] = await get_app_version()
160
164
 
161
165
  with open("tabular_swagger.yaml", "r") as f:
@@ -172,22 +176,11 @@ async def app_factory():
172
176
  async def on_cleanup(app):
173
177
  await app["csession"].close()
174
178
 
175
- app = web.Application()
179
+ app = web.Application(middlewares=[cors_middleware])
176
180
  app.add_routes(routes)
177
181
  app.on_startup.append(on_startup)
178
182
  app.on_cleanup.append(on_cleanup)
179
183
 
180
- cors = aiohttp_cors.setup(
181
- app,
182
- defaults={
183
- "*": aiohttp_cors.ResourceOptions(
184
- allow_credentials=True, expose_headers="*", allow_headers="*"
185
- )
186
- },
187
- )
188
- for route in list(app.router.routes()):
189
- cors.add(route)
190
-
191
184
  return app
192
185
 
193
186
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: udata-hydra-csvapi
3
- Version: 0.3.2.dev9
3
+ Version: 0.3.2.dev10
4
4
  Summary: API for CSV converted by udata-hydra
5
5
  Author-email: "data.gouv.fr" <opendatateam@data.gouv.fr>
6
6
  License-Expression: MIT
@@ -8,7 +8,6 @@ Requires-Python: <3.14,>=3.11
8
8
  Description-Content-Type: text/markdown
9
9
  License-File: LICENSE
10
10
  Requires-Dist: aiohttp<4.0.0,>=3.13.3
11
- Requires-Dist: aiohttp-cors<1.0.0,>=0.8.1
12
11
  Requires-Dist: aiohttp-swagger<2.0.0,>=1.0.16
13
12
  Requires-Dist: gunicorn<24.0.0,>=23.0.0
14
13
  Requires-Dist: sentry-sdk<3.0.0,>=2.49.0
@@ -323,7 +322,9 @@ column_name__sort=desc
323
322
  ```
324
323
 
325
324
  #### Aggregation Operators
326
- > ⚠️ **WARNING**: Aggregation requests are only available for resources that are listed in the `ALLOW_AGGREGATION` list of the config file, which can be seen at the `/api/aggregation-exceptions/` endpoint, and on columns that have an index.
325
+ > ⚠️ **WARNING**: Aggregation requests are disabled by default.
326
+ > You can allow or disallow it for all resources with the `ALLOW_AGGREGATION` config. If `ALLOW_AGGREGATION` is set to `false`, you can specify allow exceptions by listing these in the `ALLOW_AGGREGATION_EXCEPTIONS` list.
327
+ > You can get the current status and exceptions at `/api/aggregation-exceptions/` endpoint.
327
328
 
328
329
  ```
329
330
  # group by values
@@ -403,6 +404,22 @@ curl http://localhost:8005/api/resources/aaaaaaaa-1111-bbbb-2222-cccccccccccc/da
403
404
  }
404
405
  }
405
406
  ```
407
+ Top level parameters are considered joined with the `AND` operator. It is possible to add nested conditions with the following syntax:
408
+ ```
409
+ or=(<column1>__<operator1>.<value1>,<column2>__<operator2>.<value2>,...)
410
+ ```
411
+ > **Note**: the conditions within an `OR` have a `.` instead of a `=` between the operator and the value.
412
+
413
+ > **Note**: the syntax for `isnull` and `isnotnull` is `<column1>__is(not)null` (no value is needed, like outisde of `OR` groups)
414
+
415
+ This scales as much as needed, but the inner operators should be `or(...)` and `and(...)` (without the `=`), like:
416
+ ```
417
+ decompte__less=10&or=(is_true__exact.true,birth__greater.1980,and(score__strictly_less.5,liste__isnotnull,or(...)))
418
+ ```
419
+ > **Note**: if the column name and/or the value of the filter contains special characters (including dots), the item should be wrapped within double quotes:
420
+ ```
421
+ or=("is true"__exact.true,and(score__strictly_less."0.1","avg.views"__greater."15.5"))
422
+ ```
406
423
 
407
424
  #### Aggregation with Filtering
408
425
  With filters and aggregators (filtering is always done **before** aggregation, no matter the order in the parameters):
@@ -479,7 +496,8 @@ Configuration is handled through TOML files and environment variables. The defau
479
496
  | `PAGE_SIZE_MAX` | `50` | Maximum allowed page size |
480
497
  | `BATCH_SIZE` | `50000` | Batch size for streaming |
481
498
  | `DOC_PATH` | `/api/doc` | Swagger documentation path |
482
- | `ALLOW_AGGREGATION` | `["dddddddd-7777-eeee-8888-ffffffffffff", "aaaaaaaa-9999-bbbb-1010-cccccccccccc"]` | List of resource IDs allowed for aggregation |
499
+ | `ALLOW_AGGREGATION` | `False` | Whether aggregation queries are allowed (`column__groupby`). If False, it can still be explicitly allowed with `ALLOW_AGGREGATION_EXCEPTIONS` |
500
+ | `ALLOW_AGGREGATION_EXCEPTIONS` | `["dddddddd-7777-eeee-8888-ffffffffffff", "aaaaaaaa-9999-bbbb-1010-cccccccccccc"]` | List of resource IDs allowed for aggregation |
483
501
 
484
502
  ### Environment Variables
485
503
 
@@ -0,0 +1,23 @@
1
+ api_tabular/__init__.py,sha256=sYG6CMz-twob-QLyfsPYRc6cWDtqWXvq_N5Dxk-U5qA,2263
2
+ api_tabular/config_default.toml,sha256=cX506NdqHW7gK_2aZjzLiz0Z4gMSl1tU_tI4i4BvuXs,421
3
+ api_tabular/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ api_tabular/core/cors.py,sha256=QY006pAnR1fMBMJJTAeRGcopL_4LkY7YoA3y8KYk4dI,825
5
+ api_tabular/core/data.py,sha256=5j-3d_el7LMoHpn6V__UdhYHD6GiJbwdH67jrnOYWZQ,1459
6
+ api_tabular/core/error.py,sha256=xRt6fkQfsiupv0tWWzdX0DJjAKbw1n0tRxFyt15VBLA,1062
7
+ api_tabular/core/health.py,sha256=NeFT1C00onvm3CbgesFAcN1N8bmelbQdEYFbmmftICQ,647
8
+ api_tabular/core/query.py,sha256=aI_lqb7LGAC-EQUVfP-q90KxcWFkDTuKmW8I7yCTvO4,10067
9
+ api_tabular/core/sentry.py,sha256=fN5MrDZszLnzLXF3b_nUymPVB0ffNn4PI7xS1GtJNyE,948
10
+ api_tabular/core/swagger.py,sha256=eSCAu0TQsT2tlQiW7i-ueZB14qTd8TfHbUypZeeB-0w,14640
11
+ api_tabular/core/url.py,sha256=af7VKfrMtKMzlHrGgtLDLuRBim4qFv7xlt2PYxAyW2g,761
12
+ api_tabular/core/utils.py,sha256=VTxPiNWE3FF6f2aX5Q21XPezCL3sVZ_KICIrGBJRm_E,1071
13
+ api_tabular/core/version.py,sha256=OKLg0l1CpDRHV2hsqVgtSihoo3tg0aMG6kzMILtQ-bY,250
14
+ api_tabular/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
15
+ api_tabular/metrics/app.py,sha256=JY7GZSqCsihEcQy7SFTiKqvoyxfGRFaJ48CovNQTq4Y,4408
16
+ api_tabular/tabular/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
17
+ api_tabular/tabular/app.py,sha256=tbf1Ni_r_bbeivxlMNllVKRoqpajZId6SiurCn1hTas,6645
18
+ api_tabular/tabular/utils.py,sha256=BL2nl04-3imBQG-RZiYvVMjxoBORi4lwrKq8NiWecLY,4487
19
+ udata_hydra_csvapi-0.3.2.dev10.dist-info/licenses/LICENSE,sha256=fb-HAYw94ffh--z1cZrWbpk6YKM0p9WFG7kdd0K7uGQ,1062
20
+ udata_hydra_csvapi-0.3.2.dev10.dist-info/METADATA,sha256=TdT5xw8FivU386X-NZPhMeoiosJCaj1JfGj7JJc-TZw,20386
21
+ udata_hydra_csvapi-0.3.2.dev10.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
22
+ udata_hydra_csvapi-0.3.2.dev10.dist-info/top_level.txt,sha256=MAaK09hxwPhIBBC0i1TdEBAgdu3W_gXnAZqk767gVak,12
23
+ udata_hydra_csvapi-0.3.2.dev10.dist-info/RECORD,,
@@ -1,22 +0,0 @@
1
- api_tabular/__init__.py,sha256=sYG6CMz-twob-QLyfsPYRc6cWDtqWXvq_N5Dxk-U5qA,2263
2
- api_tabular/config_default.toml,sha256=3zQMpzr87qwd23dG_scGUptj0T3IUOjaHecI4ppJBqY,384
3
- api_tabular/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
- api_tabular/core/data.py,sha256=5j-3d_el7LMoHpn6V__UdhYHD6GiJbwdH67jrnOYWZQ,1459
5
- api_tabular/core/error.py,sha256=xRt6fkQfsiupv0tWWzdX0DJjAKbw1n0tRxFyt15VBLA,1062
6
- api_tabular/core/health.py,sha256=5VAgsu2t1Ave9n3YduzM3E7aUflBJ3pY1LUrrjT-d6w,775
7
- api_tabular/core/query.py,sha256=m98cO16mVkz0cSNICesgjC8MNVEfDW_645sz-DTd2Pw,5576
8
- api_tabular/core/sentry.py,sha256=fN5MrDZszLnzLXF3b_nUymPVB0ffNn4PI7xS1GtJNyE,948
9
- api_tabular/core/swagger.py,sha256=0RZFu941CD3qWt2R6e3nnlcrkYjjAHU5eOFl4jH2liA,14188
10
- api_tabular/core/url.py,sha256=af7VKfrMtKMzlHrGgtLDLuRBim4qFv7xlt2PYxAyW2g,761
11
- api_tabular/core/utils.py,sha256=SWU1gVkOq728-eZHmZzlyA3pQg6kelEf3SMlL-uEbns,1032
12
- api_tabular/core/version.py,sha256=OKLg0l1CpDRHV2hsqVgtSihoo3tg0aMG6kzMILtQ-bY,250
13
- api_tabular/metrics/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
- api_tabular/metrics/app.py,sha256=5kZXzAiFh1tSNYO5GEz67m4zTsTVyoIkgcelDGyT1zE,4637
15
- api_tabular/tabular/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
16
- api_tabular/tabular/app.py,sha256=JgqL1svglQ_fD5ndhft_Cm7HmTh5FuyoxYUPGS1_l-o,6771
17
- api_tabular/tabular/utils.py,sha256=BL2nl04-3imBQG-RZiYvVMjxoBORi4lwrKq8NiWecLY,4487
18
- udata_hydra_csvapi-0.3.2.dev9.dist-info/licenses/LICENSE,sha256=fb-HAYw94ffh--z1cZrWbpk6YKM0p9WFG7kdd0K7uGQ,1062
19
- udata_hydra_csvapi-0.3.2.dev9.dist-info/METADATA,sha256=70xGNtnDPIZYktyfsrYw4bhn6SDDBASB6jUQCgpHRrk,19156
20
- udata_hydra_csvapi-0.3.2.dev9.dist-info/WHEEL,sha256=YCfwYGOYMi5Jhw2fU4yNgwErybb2IX5PEwBKV4ZbdBo,91
21
- udata_hydra_csvapi-0.3.2.dev9.dist-info/top_level.txt,sha256=MAaK09hxwPhIBBC0i1TdEBAgdu3W_gXnAZqk767gVak,12
22
- udata_hydra_csvapi-0.3.2.dev9.dist-info/RECORD,,