udata-hydra 2.0.0.dev1648__tar.gz → 2.0.0.dev1729__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.
Files changed (45) hide show
  1. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/PKG-INFO +33 -8
  2. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/README.md +32 -7
  3. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/pyproject.toml +14 -3
  4. udata_hydra-2.0.0.dev1729/setup.py +59 -0
  5. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/analysis/csv.py +61 -32
  6. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/analysis/errors.py +0 -1
  7. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/analysis/helpers.py +2 -1
  8. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/analysis/resource.py +42 -24
  9. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/app.py +9 -23
  10. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/cli.py +8 -17
  11. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/context.py +5 -9
  12. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/crawl.py +35 -23
  13. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/logger.py +0 -1
  14. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/__init__.py +0 -1
  15. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/csv.py +8 -8
  16. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/file.py +12 -6
  17. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/http.py +7 -2
  18. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/minio.py +2 -7
  19. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/reader.py +4 -3
  20. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/timer.py +1 -0
  21. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/worker.py +0 -1
  22. udata_hydra-2.0.0.dev1648/setup.py +0 -59
  23. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/__init__.py +1 -1
  24. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/analysis/__init__.py +0 -0
  25. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/config_default.toml +0 -0
  26. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/csv/20221205_initial_up_rev1.sql +0 -0
  27. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/csv/20230130_drop_migrations.sql +0 -0
  28. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/csv/20230206_datetime_aware.sql +0 -0
  29. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20221205_initial_up_rev1.sql +0 -0
  30. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20221206_rev1_up_rev2.sql +0 -0
  31. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20221206_rev2_up_rev3.sql +0 -0
  32. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20221208_rev3_up_rev4.sql +0 -0
  33. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20221208_rev4_up_rev5.sql +0 -0
  34. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230119_rev5_up_rev6.sql +0 -0
  35. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230121_rev6_up_rev7.sql +0 -0
  36. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230121_rev7_up_rev8.sql +0 -0
  37. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230130_drop_migrations.sql +0 -0
  38. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230206_datetime_aware.sql +0 -0
  39. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230515_rev8_up_rev9.sql +0 -0
  40. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20230606_rev9_up_rev10.sql +0 -0
  41. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/migrations/main/20231102_drop_csv_analysis.sql +0 -0
  42. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/__init__.py +0 -0
  43. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/db.py +0 -0
  44. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/json.py +0 -0
  45. {udata_hydra-2.0.0.dev1648 → udata_hydra-2.0.0.dev1729}/udata_hydra/utils/queue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: udata-hydra
3
- Version: 2.0.0.dev1648
3
+ Version: 2.0.0.dev1729
4
4
  Summary: Async crawler and parsing service for data.gouv.fr
5
5
  License: MIT
6
6
  Author: Opendata Team
@@ -45,6 +45,17 @@ Since it's called _hydra_, it also has mythical powers embedded:
45
45
  - if the remote resource is a CSV, convert it to a PostgreSQL table, ready for APIfication
46
46
  - send crawl and analysis info to a udata instance
47
47
 
48
+ ## Architecture schema
49
+
50
+ The architecture for the full workflow is the following:
51
+
52
+ ![Full workflow architecture](docs/archi-idd-IDD.drawio.png)
53
+
54
+
55
+ The hydra crawler is one of the components of the architecture. It will check if resource is available, analyze the type of file if the resource has been modified, and analyze the CSV content. It will also convert CSV resources to database tables and send the data to a udata instance.
56
+
57
+ ![Crawler architecture](docs/hydra.drawio.png)
58
+
48
59
  ## CLI
49
60
 
50
61
  ### Create database structure
@@ -62,7 +73,7 @@ Install udata-hydra dependencies and cli.
62
73
 
63
74
  `poetry run udata-hydra-crawl`
64
75
 
65
- It will crawl (forever) the catalog according to config set in `config.py`.
76
+ It will crawl (forever) the catalog according to config set in `udata_hydra/config.toml`, with a default config in `udata_hydra/config_default.toml`.
66
77
 
67
78
  `BATCH_SIZE` URLs are queued at each loop run.
68
79
 
@@ -90,7 +101,7 @@ Converted CSV tables will be stored in the database specified via `config.DATABA
90
101
 
91
102
  ### Run
92
103
 
93
- ```
104
+ ```bash
94
105
  poetry install
95
106
  poetry run adev runserver udata_hydra/app.py
96
107
  ```
@@ -99,7 +110,7 @@ poetry run adev runserver udata_hydra/app.py
99
110
 
100
111
  Works with `?url={url}` and `?resource_id={resource_id}`.
101
112
 
102
- ```
113
+ ```bash
103
114
  $ curl -s "http://localhost:8000/api/checks/latest/?url=http://opendata-sig.saintdenis.re/datasets/661e19974bcc48849bbff7c9637c5c28_1.csv" | json_pp
104
115
  {
105
116
  "status" : 200,
@@ -136,7 +147,7 @@ $ curl -s "http://localhost:8000/api/checks/latest/?url=http://opendata-sig.sain
136
147
 
137
148
  Works with `?url={url}` and `?resource_id={resource_id}`.
138
149
 
139
- ```
150
+ ```bash
140
151
  $ curl -s "http://localhost:8000/api/checks/all/?url=http://www.drees.sante.gouv.fr/IMG/xls/er864.xls" | json_pp
141
152
  [
142
153
  {
@@ -174,7 +185,7 @@ $ curl -s "http://localhost:8000/api/checks/all/?url=http://www.drees.sante.gouv
174
185
 
175
186
  ### Get crawling status
176
187
 
177
- ```
188
+ ```bash
178
189
  $ curl -s "http://localhost:8000/api/status/crawler/" | json_pp
179
190
  {
180
191
  "fresh_checks_percentage" : 0.4,
@@ -187,7 +198,7 @@ $ curl -s "http://localhost:8000/api/status/crawler/" | json_pp
187
198
 
188
199
  ### Get worker status
189
200
 
190
- ```
201
+ ```bash
191
202
  $ curl -s "http://localhost:8000/api/status/worker/" | json_pp
192
203
  {
193
204
  "queued" : {
@@ -200,7 +211,7 @@ $ curl -s "http://localhost:8000/api/status/worker/" | json_pp
200
211
 
201
212
  ### Get crawling stats
202
213
 
203
- ```
214
+ ```bash
204
215
  $ curl -s "http://localhost:8000/api/stats/" | json_pp
205
216
  {
206
217
  "status" : [
@@ -313,3 +324,17 @@ Refer to each section to learn how to launch them. The only differences from dev
313
324
  - use `HYDRA_SETTINGS` env var to point to your custom `config.toml`
314
325
  - use `HYDRA_APP_SOCKET_PATH` to configure where aiohttp should listen to a [reverse proxy connection (eg nginx)](https://docs.aiohttp.org/en/stable/deployment.html#nginx-configuration) and use `udata-hydra-app` to launch the app server
315
326
 
327
+ ## Contributing
328
+
329
+ Before contributing to the repository and making any PR, it is necessary to initialize the pre-commit hooks:
330
+ ```bash
331
+ pre-commit install
332
+ ```
333
+ Once this is done, code formatting and linting, as well as import sorting, will be automatically checked before each commit.
334
+
335
+ If you cannot use pre-commit, it is necessary to format, lint, and sort imports with [Ruff](https://docs.astral.sh/ruff/) before committing:
336
+ ```bash
337
+ ruff check --fix --select I .
338
+ ruff format .
339
+ ```
340
+
@@ -9,6 +9,17 @@ Since it's called _hydra_, it also has mythical powers embedded:
9
9
  - if the remote resource is a CSV, convert it to a PostgreSQL table, ready for APIfication
10
10
  - send crawl and analysis info to a udata instance
11
11
 
12
+ ## Architecture schema
13
+
14
+ The architecture for the full workflow is the following:
15
+
16
+ ![Full workflow architecture](docs/archi-idd-IDD.drawio.png)
17
+
18
+
19
+ The hydra crawler is one of the components of the architecture. It will check if resource is available, analyze the type of file if the resource has been modified, and analyze the CSV content. It will also convert CSV resources to database tables and send the data to a udata instance.
20
+
21
+ ![Crawler architecture](docs/hydra.drawio.png)
22
+
12
23
  ## CLI
13
24
 
14
25
  ### Create database structure
@@ -26,7 +37,7 @@ Install udata-hydra dependencies and cli.
26
37
 
27
38
  `poetry run udata-hydra-crawl`
28
39
 
29
- It will crawl (forever) the catalog according to config set in `config.py`.
40
+ It will crawl (forever) the catalog according to config set in `udata_hydra/config.toml`, with a default config in `udata_hydra/config_default.toml`.
30
41
 
31
42
  `BATCH_SIZE` URLs are queued at each loop run.
32
43
 
@@ -54,7 +65,7 @@ Converted CSV tables will be stored in the database specified via `config.DATABA
54
65
 
55
66
  ### Run
56
67
 
57
- ```
68
+ ```bash
58
69
  poetry install
59
70
  poetry run adev runserver udata_hydra/app.py
60
71
  ```
@@ -63,7 +74,7 @@ poetry run adev runserver udata_hydra/app.py
63
74
 
64
75
  Works with `?url={url}` and `?resource_id={resource_id}`.
65
76
 
66
- ```
77
+ ```bash
67
78
  $ curl -s "http://localhost:8000/api/checks/latest/?url=http://opendata-sig.saintdenis.re/datasets/661e19974bcc48849bbff7c9637c5c28_1.csv" | json_pp
68
79
  {
69
80
  "status" : 200,
@@ -100,7 +111,7 @@ $ curl -s "http://localhost:8000/api/checks/latest/?url=http://opendata-sig.sain
100
111
 
101
112
  Works with `?url={url}` and `?resource_id={resource_id}`.
102
113
 
103
- ```
114
+ ```bash
104
115
  $ curl -s "http://localhost:8000/api/checks/all/?url=http://www.drees.sante.gouv.fr/IMG/xls/er864.xls" | json_pp
105
116
  [
106
117
  {
@@ -138,7 +149,7 @@ $ curl -s "http://localhost:8000/api/checks/all/?url=http://www.drees.sante.gouv
138
149
 
139
150
  ### Get crawling status
140
151
 
141
- ```
152
+ ```bash
142
153
  $ curl -s "http://localhost:8000/api/status/crawler/" | json_pp
143
154
  {
144
155
  "fresh_checks_percentage" : 0.4,
@@ -151,7 +162,7 @@ $ curl -s "http://localhost:8000/api/status/crawler/" | json_pp
151
162
 
152
163
  ### Get worker status
153
164
 
154
- ```
165
+ ```bash
155
166
  $ curl -s "http://localhost:8000/api/status/worker/" | json_pp
156
167
  {
157
168
  "queued" : {
@@ -164,7 +175,7 @@ $ curl -s "http://localhost:8000/api/status/worker/" | json_pp
164
175
 
165
176
  ### Get crawling stats
166
177
 
167
- ```
178
+ ```bash
168
179
  $ curl -s "http://localhost:8000/api/stats/" | json_pp
169
180
  {
170
181
  "status" : [
@@ -276,3 +287,17 @@ For example, to set the log level to `DEBUG` when initializing the database, use
276
287
  Refer to each section to learn how to launch them. The only differences from dev to prod are:
277
288
  - use `HYDRA_SETTINGS` env var to point to your custom `config.toml`
278
289
  - use `HYDRA_APP_SOCKET_PATH` to configure where aiohttp should listen to a [reverse proxy connection (eg nginx)](https://docs.aiohttp.org/en/stable/deployment.html#nginx-configuration) and use `udata-hydra-app` to launch the app server
290
+
291
+ ## Contributing
292
+
293
+ Before contributing to the repository and making any PR, it is necessary to initialize the pre-commit hooks:
294
+ ```bash
295
+ pre-commit install
296
+ ```
297
+ Once this is done, code formatting and linting, as well as import sorting, will be automatically checked before each commit.
298
+
299
+ If you cannot use pre-commit, it is necessary to format, lint, and sort imports with [Ruff](https://docs.astral.sh/ruff/) before committing:
300
+ ```bash
301
+ ruff check --fix --select I .
302
+ ruff format .
303
+ ```
@@ -1,6 +1,15 @@
1
+ [project]
2
+ name = "udata-hydra"
3
+ version = "2.0.0.dev"
4
+ description = "Async crawler and parsing service for data.gouv.fr"
5
+ authors = [{ name = "Opendata Team", email = "opendatateam@data.gouv.fr" }]
6
+ requires-python = ">=3.9,<3.13"
7
+ license = { text = "MIT" }
8
+ readme = "README.md"
9
+
1
10
  [tool.poetry]
2
11
  name = "udata-hydra"
3
- version = "2.0.0.dev1648"
12
+ version = "2.0.0.dev1729"
4
13
  description = "Async crawler and parsing service for data.gouv.fr"
5
14
  authors = ["Opendata Team <opendatateam@data.gouv.fr>"]
6
15
  license = "MIT"
@@ -31,8 +40,6 @@ python-dateutil = "^2.8.2"
31
40
  csv-detective = "0.7.1"
32
41
 
33
42
  [tool.poetry.group.dev.dependencies]
34
- flake8-quotes = "^3.3.1"
35
- flake8 = "^4.0.1"
36
43
  gunicorn = "^20.1.0"
37
44
  aiohttp-devtools = "^1.0.post0"
38
45
  aioresponses = "^0.7.3"
@@ -41,7 +48,11 @@ pytest = "^7.1.1"
41
48
  pytest-mock = "^3.7.0"
42
49
  pytest-asyncio = "^0.18.3"
43
50
  bumpr = "^0.3.8"
51
+ ruff = "^0.5.2"
44
52
 
53
+ [tool.ruff]
54
+ lint = { select = ["I"] } # also sort imports with an isort rule
55
+ line-length = 100
45
56
 
46
57
  [build-system]
47
58
  requires = ["poetry-core>=1.0.0"]
@@ -0,0 +1,59 @@
1
+ # -*- coding: utf-8 -*-
2
+ from setuptools import setup
3
+
4
+ packages = \
5
+ ['udata_hydra',
6
+ 'udata_hydra.analysis',
7
+ 'udata_hydra.migrations',
8
+ 'udata_hydra.utils']
9
+
10
+ package_data = \
11
+ {'': ['*'], 'udata_hydra.migrations': ['csv/*', 'main/*']}
12
+
13
+ install_requires = \
14
+ ['aiocontextvars>=0.2.2,<0.3.0',
15
+ 'aiohttp>=3.8.1,<4.0.0',
16
+ 'asyncpg>=0.27.0,<0.28.0',
17
+ 'boto3>=1.21.21,<2.0.0',
18
+ 'coloredlogs>=15.0.1,<16.0.0',
19
+ 'csv-detective==0.7.1',
20
+ 'dateparser>=1.1.7,<2.0.0',
21
+ 'humanfriendly>=10.0,<11.0',
22
+ 'marshmallow>=3.14.1,<4.0.0',
23
+ 'minicli>=0.5.0,<0.6.0',
24
+ 'pandas>=1.3.3,<2.0.0',
25
+ 'progressist>=0.1.0,<0.2.0',
26
+ 'python-dateutil>=2.8.2,<3.0.0',
27
+ 'python-magic>=0.4.25,<0.5.0',
28
+ 'redis>=4.1.4,<5.0.0',
29
+ 'rq>=1.11.1,<2.0.0',
30
+ 'sentry-sdk>=1.11.1,<2.0.0',
31
+ 'sqlalchemy>=1.4.46,<2.0.0',
32
+ 'str2bool>=1.1,<2.0',
33
+ 'str2float>=0.0.9,<0.0.10',
34
+ 'toml>=0.10.2,<0.11.0']
35
+
36
+ entry_points = \
37
+ {'console_scripts': ['udata-hydra = udata_hydra.cli:run',
38
+ 'udata-hydra-app = udata_hydra.app:run',
39
+ 'udata-hydra-crawl = udata_hydra.crawl:run']}
40
+
41
+ setup_kwargs = {
42
+ 'name': 'udata-hydra',
43
+ 'version': '2.0.0.dev1729',
44
+ 'description': 'Async crawler and parsing service for data.gouv.fr',
45
+ 'long_description': '# udata-hydra 🦀\n\n`udata-hydra` is an async metadata crawler for [data.gouv.fr](https://www.data.gouv.fr).\n\nURLs are crawled via _aiohttp_, catalog and crawled metadata are stored in a _PostgreSQL_ database.\n\nSince it\'s called _hydra_, it also has mythical powers embedded:\n- analyse remote resource metadata over time to detect changes in the smartest way possible\n- if the remote resource is a CSV, convert it to a PostgreSQL table, ready for APIfication\n- send crawl and analysis info to a udata instance\n\n## Architecture schema\n\nThe architecture for the full workflow is the following:\n\n![Full workflow architecture](docs/archi-idd-IDD.drawio.png)\n\n\nThe hydra crawler is one of the components of the architecture. It will check if resource is available, analyze the type of file if the resource has been modified, and analyze the CSV content. It will also convert CSV resources to database tables and send the data to a udata instance. \n\n![Crawler architecture](docs/hydra.drawio.png)\n\n## CLI\n\n### Create database structure\n\nInstall udata-hydra dependencies and cli.\n`poetry install`\n\n`poetry run udata-hydra migrate`\n\n### Load (UPSERT) latest catalog version from data.gouv.fr\n\n`poetry run udata-hydra load-catalog`\n\n## Crawler\n\n`poetry run udata-hydra-crawl`\n\nIt will crawl (forever) the catalog according to config set in `udata_hydra/config.toml`, with a default config in `udata_hydra/config_default.toml`.\n\n`BATCH_SIZE` URLs are queued at each loop run.\n\nThe crawler will start with URLs never checked and then proceed with URLs crawled before `SINCE` interval. It will then wait until something changes (catalog or time).\n\nThere\'s a by-domain backoff mecanism. The crawler will wait when, for a given domain in a given batch, `BACKOFF_NB_REQ` is exceeded in a period of `BACKOFF_PERIOD` seconds. It will retry until the backoff is lifted.\n\nIf an URL matches one of the `EXCLUDED_PATTERNS`, it will never be checked.\n\n## Worker\n\nA job queuing system is used to process long-running tasks. Launch the worker with the following command:\n\n`poetry run rq worker -c udata_hydra.worker`\n\nMonitor worker status:\n\n`poetry run rq info -c udata_hydra.worker --interval 1`\n\n## CSV conversion to database\n\nConverted CSV tables will be stored in the database specified via `config.DATABASE_URL_CSV`. For tests it\'s same database as for the catalog. Locally, `docker compose` will launch two distinct database containers.\n\n## API\n\n### Run\n\n```bash\npoetry install\npoetry run adev runserver udata_hydra/app.py\n```\n\n### Get latest check\n\nWorks with `?url={url}` and `?resource_id={resource_id}`.\n\n```bash\n$ curl -s "http://localhost:8000/api/checks/latest/?url=http://opendata-sig.saintdenis.re/datasets/661e19974bcc48849bbff7c9637c5c28_1.csv" | json_pp\n{\n "status" : 200,\n "catalog_id" : 64148,\n "deleted" : false,\n "error" : null,\n "created_at" : "2021-02-06T12:19:08.203055",\n "response_time" : 0.830198049545288,\n "url" : "http://opendata-sig.saintdenis.re/datasets/661e19974bcc48849bbff7c9637c5c28_1.csv",\n "domain" : "opendata-sig.saintdenis.re",\n "timeout" : false,\n "id" : 114750,\n "dataset_id" : "5c34944606e3e73d4a551889",\n "resource_id" : "b3678c59-5b35-43ad-9379-fce29e5b56fe",\n "headers" : {\n "content-disposition" : "attachment; filename=\\"xn--Dlimitation_des_cantons-bcc.csv\\"",\n "server" : "openresty",\n "x-amz-meta-cachetime" : "191",\n "last-modified" : "Wed, 29 Apr 2020 02:19:04 GMT",\n "content-encoding" : "gzip",\n "content-type" : "text/csv",\n "cache-control" : "must-revalidate",\n "etag" : "\\"20415964703d9ccc4815d7126aa3a6d8\\"",\n "content-length" : "207",\n "date" : "Sat, 06 Feb 2021 12:19:08 GMT",\n "x-amz-meta-contentlastmodified" : "2018-11-19T09:38:28.490Z",\n "connection" : "keep-alive",\n "vary" : "Accept-Encoding"\n }\n}\n```\n\n### Get all checks for an URL or resource\n\nWorks with `?url={url}` and `?resource_id={resource_id}`.\n\n```bash\n$ curl -s "http://localhost:8000/api/checks/all/?url=http://www.drees.sante.gouv.fr/IMG/xls/er864.xls" | json_pp\n[\n {\n "domain" : "www.drees.sante.gouv.fr",\n "dataset_id" : "53d6eadba3a72954d9dd62f5",\n "timeout" : false,\n "deleted" : false,\n "response_time" : null,\n "error" : "Cannot connect to host www.drees.sante.gouv.fr:443 ssl:True [SSLCertVerificationError: (1, \\"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for \'www.drees.sante.gouv.fr\'. (_ssl.c:1122)\\")]",\n "catalog_id" : 232112,\n "url" : "http://www.drees.sante.gouv.fr/IMG/xls/er864.xls",\n "headers" : {},\n "id" : 165107,\n "created_at" : "2021-02-06T14:32:47.675854",\n "resource_id" : "93dfd449-9d26-4bb0-a6a9-ee49b1b8a4d7",\n "status" : null\n },\n {\n "timeout" : false,\n "deleted" : false,\n "response_time" : null,\n "error" : "Cannot connect to host www.drees.sante.gouv.fr:443 ssl:True [SSLCertVerificationError: (1, \\"[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: Hostname mismatch, certificate is not valid for \'www.drees.sante.gouv.fr\'. (_ssl.c:1122)\\")]",\n "domain" : "www.drees.sante.gouv.fr",\n "dataset_id" : "53d6eadba3a72954d9dd62f5",\n "created_at" : "2020-12-24T17:06:58.158125",\n "resource_id" : "93dfd449-9d26-4bb0-a6a9-ee49b1b8a4d7",\n "status" : null,\n "catalog_id" : 232112,\n "url" : "http://www.drees.sante.gouv.fr/IMG/xls/er864.xls",\n "headers" : {},\n "id" : 65092\n }\n]\n```\n\n### Get crawling status\n\n```bash\n$ curl -s "http://localhost:8000/api/status/crawler/" | json_pp\n{\n "fresh_checks_percentage" : 0.4,\n "pending_checks" : 142153,\n "total" : 142687,\n "fresh_checks" : 534,\n "checks_percentage" : 0.4\n}\n```\n\n### Get worker status\n\n```bash\n$ curl -s "http://localhost:8000/api/status/worker/" | json_pp\n{\n "queued" : {\n "default" : 0,\n "high" : 825,\n "low" : 655\n }\n}\n```\n\n### Get crawling stats\n\n```bash\n$ curl -s "http://localhost:8000/api/stats/" | json_pp\n{\n "status" : [\n {\n "count" : 525,\n "percentage" : 98.3,\n "label" : "ok"\n },\n {\n "label" : "error",\n "percentage" : 1.3,\n "count" : 7\n },\n {\n "label" : "timeout",\n "percentage" : 0.4,\n "count" : 2\n }\n ],\n "status_codes" : [\n {\n "code" : 200,\n "count" : 413,\n "percentage" : 78.7\n },\n {\n "code" : 501,\n "percentage" : 12.4,\n "count" : 65\n },\n {\n "percentage" : 6.1,\n "count" : 32,\n "code" : 404\n },\n {\n "code" : 500,\n "percentage" : 2.7,\n "count" : 14\n },\n {\n "code" : 502,\n "count" : 1,\n "percentage" : 0.2\n }\n ]\n}\n```\n\n## Using Webhook integration\n\n** Set the config values**\n\nCreate a `config.toml` where your service and commands are launched, or specify a path to a TOML file via the `HYDRA_SETTINGS` environment variable. `config.toml` or equivalent will override values from `udata_hydra/config_default.toml`, lookup there for values that can/need to be defined.\n\n```toml\nUDATA_URI = "https://dev.local:7000/api/2"\nUDATA_URI_API_KEY = "example.api.key"\nSENTRY_DSN = "https://{my-sentry-dsn}"\n```\n\nThe webhook integration sends HTTP messages to `udata` when resources are analyzed or checked to fill resources extras.\n\nRegarding analysis, there is a phase called "change detection". It will try to guess if a resource has been modified based on different criterions:\n- harvest modified date in catalog\n- content-length and last-modified headers\n- checksum comparison over time\n\nThe payload should look something like:\n\n```json\n{\n "analysis:content-length": 91661,\n "analysis:mime-type": "application/zip",\n "analysis:checksum": "bef1de04601dedaf2d127418759b16915ba083be",\n "analysis:last-modified-at": "2022-11-27T23:00:54.762000",\n "analysis:last-modified-detection": "harvest-resource-metadata",\n}\n```\n\n## Development\n\n### docker-compose\n\nMultiple docker-compose files are provided:\n- a minimal `docker-compose.yml` with two PostgreSQL containers (one for catalog and metadata, the other for converted CSV to database)\n- `docker-compose.broker.yml` adds a Redis broker\n- `docker-compose.test.yml` launches a test DB, needed to run tests\n\nNB: you can launch compose from multiple files like this: `docker-compose -f docker-compose.yml -f docker-compose.test.yml up`\n\n### Logging & Debugging\n\nThe log level can be adjusted using the environment variable LOG_LEVEL.\nFor example, to set the log level to `DEBUG` when initializing the database, use `LOG_LEVEL="DEBUG" udata-hydra init_db `.\n\n### Writing a migration\n\n1. Add a file named `migrations/{YYYYMMDD}_{description}.sql` and write the SQL you need to perform migration.\n2. `udata-hydra migrate` will migrate the database as needeed.\n\n## Deployment\n\n3 services need to be deployed for the full stack to run:\n- worker\n- api / app\n- crawler\n\nRefer to each section to learn how to launch them. The only differences from dev to prod are:\n- use `HYDRA_SETTINGS` env var to point to your custom `config.toml`\n- use `HYDRA_APP_SOCKET_PATH` to configure where aiohttp should listen to a [reverse proxy connection (eg nginx)](https://docs.aiohttp.org/en/stable/deployment.html#nginx-configuration) and use `udata-hydra-app` to launch the app server\n\n## Contributing\n\nBefore contributing to the repository and making any PR, it is necessary to initialize the pre-commit hooks:\n```bash\npre-commit install\n```\nOnce this is done, code formatting and linting, as well as import sorting, will be automatically checked before each commit.\n\nIf you cannot use pre-commit, it is necessary to format, lint, and sort imports with [Ruff](https://docs.astral.sh/ruff/) before committing:\n```bash\nruff check --fix --select I .\nruff format .\n```\n',
46
+ 'author': 'Opendata Team',
47
+ 'author_email': 'opendatateam@data.gouv.fr',
48
+ 'maintainer': 'None',
49
+ 'maintainer_email': 'None',
50
+ 'url': 'None',
51
+ 'packages': packages,
52
+ 'package_data': package_data,
53
+ 'install_requires': install_requires,
54
+ 'entry_points': entry_points,
55
+ 'python_requires': '>=3.9,<4.0',
56
+ }
57
+
58
+
59
+ setup(**setup_kwargs)
@@ -3,36 +3,42 @@ import hashlib
3
3
  import json
4
4
  import logging
5
5
  import os
6
- import pytz
7
6
  import sys
8
-
9
7
  from datetime import datetime
10
8
  from typing import Any
11
9
 
10
+ import pytz
12
11
  import sentry_sdk
13
-
14
- from csv_detective.explore_csv import routine as csv_detective_routine
15
12
  from csv_detective.detection import engine_to_file
13
+ from csv_detective.explore_csv import routine as csv_detective_routine
16
14
  from progressist import ProgressBar
17
15
  from sqlalchemy import (
18
- MetaData, Table, Column,
19
- BigInteger, String, Float, Boolean, Integer, JSON, Date, DateTime,
16
+ JSON,
17
+ BigInteger,
18
+ Boolean,
19
+ Column,
20
+ Date,
21
+ DateTime,
22
+ Float,
23
+ Integer,
24
+ MetaData,
25
+ String,
26
+ Table,
20
27
  )
21
28
  from sqlalchemy.dialects.postgresql import asyncpg
22
29
  from sqlalchemy.schema import CreateTable
23
30
  from str2bool import str2bool
24
31
  from str2float import str2float
25
32
 
26
- from udata_hydra import context, config
33
+ from udata_hydra import config, context
27
34
  from udata_hydra.analysis import helpers
28
35
  from udata_hydra.analysis.errors import ParseException
29
- from udata_hydra.utils.db import get_check, compute_insert_query, update_check
36
+ from udata_hydra.utils import queue
37
+ from udata_hydra.utils.db import compute_insert_query, get_check, update_check
30
38
  from udata_hydra.utils.file import download_resource
31
- from udata_hydra.utils.timer import Timer
32
39
  from udata_hydra.utils.http import send
33
40
  from udata_hydra.utils.reader import Reader
34
- from udata_hydra.utils import queue
35
-
41
+ from udata_hydra.utils.timer import Timer
36
42
 
37
43
  log = logging.getLogger("udata-hydra")
38
44
 
@@ -82,15 +88,22 @@ async def notify_udata(check_id):
82
88
  "document": {
83
89
  "analysis:parsing:error": check["parsing_error"],
84
90
  "analysis:parsing:started_at": check["parsing_started_at"].isoformat()
85
- if check["parsing_started_at"] else None,
91
+ if check["parsing_started_at"]
92
+ else None,
86
93
  "analysis:parsing:finished_at": check["parsing_finished_at"].isoformat()
87
- if check["parsing_finished_at"] else None,
88
- }
94
+ if check["parsing_finished_at"]
95
+ else None,
96
+ },
89
97
  }
90
98
  queue.enqueue(send, _priority="high", **payload)
91
99
 
92
100
 
93
- async def analyse_csv(check_id: int = None, url: str = None, file_path: str = None, debug_insert: bool = False) -> None:
101
+ async def analyse_csv(
102
+ check_id: int = None,
103
+ url: str = None,
104
+ file_path: str = None,
105
+ debug_insert: bool = False,
106
+ ) -> None:
94
107
  """Launch csv analysis from a check or an URL (debug), using previsously downloaded file at file_path if any"""
95
108
  if not config.CSV_ANALYSIS_ENABLED:
96
109
  log.debug("CSV_ANALYSIS_ENABLED turned off, skipping.")
@@ -105,10 +118,14 @@ async def analyse_csv(check_id: int = None, url: str = None, file_path: str = No
105
118
  exception_file = str(check.get("resource_id", "")) in exceptions
106
119
 
107
120
  headers = json.loads(check.get("headers") or "{}")
108
- tmp_file = open(file_path, "rb") if file_path else await download_resource(
109
- url=url,
110
- headers=headers,
111
- max_size_allowed=None if exception_file else float(config.MAX_FILESIZE_ALLOWED["csv"]),
121
+ tmp_file = (
122
+ open(file_path, "rb")
123
+ if file_path
124
+ else await download_resource(
125
+ url=url,
126
+ headers=headers,
127
+ max_size_allowed=None if exception_file else float(config.MAX_FILESIZE_ALLOWED["csv"]),
128
+ )
112
129
  )
113
130
  table_name = hashlib.md5(url.encode("utf-8")).hexdigest()
114
131
  timer.mark("download-file")
@@ -121,10 +138,13 @@ async def analyse_csv(check_id: int = None, url: str = None, file_path: str = No
121
138
  await csv_to_db(tmp_file.name, csv_inspection, table_name, debug_insert=debug_insert)
122
139
  timer.mark("csv-to-db")
123
140
  if check_id:
124
- await update_check(check_id, {
125
- "parsing_table": table_name,
126
- "parsing_finished_at": datetime.now(pytz.UTC),
127
- })
141
+ await update_check(
142
+ check_id,
143
+ {
144
+ "parsing_table": table_name,
145
+ "parsing_finished_at": datetime.now(pytz.UTC),
146
+ },
147
+ )
128
148
  await csv_to_db_index(table_name, csv_inspection, check)
129
149
  except ParseException as e:
130
150
  await handle_parse_exception(e, check_id, table_name)
@@ -196,11 +216,9 @@ async def csv_to_db(file_path: str, inspection: dict, table_name: str, debug_ins
196
216
  await db.execute(q)
197
217
  with Reader(file_path, inspection) as reader:
198
218
  records = (
199
- [
200
- smart_cast(t, v, failsafe=True)
201
- for t, v in zip(columns.values(), line)
202
- ]
203
- for line in reader if line
219
+ [smart_cast(t, v, failsafe=True) for t, v in zip(columns.values(), line)]
220
+ for line in reader
221
+ if line
204
222
  )
205
223
  # this use postgresql COPY from an iterator, it's fast but might be difficult to debug
206
224
  if not debug_insert:
@@ -223,13 +241,21 @@ async def csv_to_db_index(table_name: str, inspection: dict, check: dict):
223
241
  """Store meta info about a converted CSV table in `DATABASE_URL_CSV.tables_index`"""
224
242
  db = await context.pool("csv")
225
243
  q = "INSERT INTO tables_index(parsing_table, csv_detective, resource_id, url) VALUES($1, $2, $3, $4)"
226
- await db.execute(q, table_name, json.dumps(inspection), check.get("resource_id"), check.get("url"))
244
+ await db.execute(
245
+ q,
246
+ table_name,
247
+ json.dumps(inspection),
248
+ check.get("resource_id"),
249
+ check.get("url"),
250
+ )
227
251
 
228
252
 
229
253
  async def perform_csv_inspection(file_path):
230
254
  """Launch csv-detective against given file"""
231
255
  try:
232
- return csv_detective_routine(file_path, output_profile=True, num_rows=-1, save_results=False)
256
+ return csv_detective_routine(
257
+ file_path, output_profile=True, num_rows=-1, save_results=False
258
+ )
233
259
  except Exception as e:
234
260
  raise ParseException("csv_detective") from e
235
261
 
@@ -242,7 +268,7 @@ async def delete_table(table_name: str):
242
268
 
243
269
  async def handle_parse_exception(e: Exception, check_id: int, table_name: str) -> None:
244
270
  """Specific ParsingError handling. Enriches sentry w/ context if available,
245
- and store error if in a check context. Also cleanup :table_name: if needed."""
271
+ and store error if in a check context. Also cleanup :table_name: if needed."""
246
272
  db = await context.pool("csv")
247
273
  await db.execute(f'DROP TABLE IF EXISTS "{table_name}"')
248
274
  if check_id:
@@ -257,7 +283,10 @@ async def handle_parse_exception(e: Exception, check_id: int, table_name: str) -
257
283
  # e.__cause__ let us access the "inherited" error of ParseException (raise e from cause)
258
284
  # it's called explicit exception chaining and it's very cool, look it up (PEP 3134)!
259
285
  err = f"{e.step}:sentry:{event_id}" if config.SENTRY_DSN else f"{e.step}:{str(e.__cause__)}"
260
- await update_check(check_id, {"parsing_error": err, "parsing_finished_at": datetime.now(pytz.UTC)})
286
+ await update_check(
287
+ check_id,
288
+ {"parsing_error": err, "parsing_finished_at": datetime.now(pytz.UTC)},
289
+ )
261
290
  log.error("Parsing error", exc_info=e)
262
291
  else:
263
292
  raise e
@@ -1,5 +1,4 @@
1
1
  class ParseException(Exception):
2
-
3
2
  def __init__(self, step, *args: object) -> None:
4
3
  self.step = step
5
4
  super().__init__(*args)
@@ -1,7 +1,8 @@
1
1
  from datetime import date, datetime
2
2
 
3
3
  from dateparser import parse as date_parser
4
- from dateutil.parser import parse as dateutil_parser, ParserError
4
+ from dateutil.parser import ParserError
5
+ from dateutil.parser import parse as dateutil_parser
5
6
 
6
7
 
7
8
  def to_json(value: str) -> str:
@@ -1,21 +1,19 @@
1
- from enum import Enum
2
1
  import json
3
2
  import logging
4
3
  import os
5
- import pytz
6
-
7
4
  from datetime import datetime
5
+ from enum import Enum
8
6
  from typing import Tuple, Union
9
7
 
10
8
  import magic
11
-
9
+ import pytz
12
10
  from dateparser import parse as date_parser
13
11
 
14
- from udata_hydra import context, config
15
- from udata_hydra.utils import queue
12
+ from udata_hydra import config, context
16
13
  from udata_hydra.analysis.csv import analyse_csv
14
+ from udata_hydra.utils import queue
17
15
  from udata_hydra.utils.csv import detect_tabular_from_headers
18
- from udata_hydra.utils.db import update_check, get_check
16
+ from udata_hydra.utils.db import get_check, update_check
19
17
  from udata_hydra.utils.file import compute_checksum_from_file, download_resource
20
18
  from udata_hydra.utils.http import send
21
19
 
@@ -60,8 +58,7 @@ async def process_resource(check_id: int, is_first_check: bool) -> None:
60
58
  # could it be a CSV? If we get hints, we will analyse the file further depending on change status
61
59
  is_tabular, file_format = await detect_tabular_from_headers(check)
62
60
  max_size_allowed = (
63
- None if str(resource_id) in exceptions
64
- else float(config.MAX_FILESIZE_ALLOWED[file_format])
61
+ None if str(resource_id) in exceptions else float(config.MAX_FILESIZE_ALLOWED[file_format])
65
62
  )
66
63
 
67
64
  # if the change status is NO_GUESS or HAS_CHANGED, let's download the file to get more infos
@@ -79,18 +76,25 @@ async def process_resource(check_id: int, is_first_check: bool) -> None:
79
76
  dl_analysis["analysis:checksum"] = compute_checksum_from_file(tmp_file.name)
80
77
  # Check if checksum has been modified if we don't have other hints
81
78
  if change_status == Change.NO_GUESS:
82
- change_status, change_payload = await detect_resource_change_from_checksum(
83
- resource_id, dl_analysis["analysis:checksum"])
79
+ (
80
+ change_status,
81
+ change_payload,
82
+ ) = await detect_resource_change_from_checksum(
83
+ resource_id, dl_analysis["analysis:checksum"]
84
+ )
84
85
  dl_analysis["analysis:mime-type"] = magic.from_file(tmp_file.name, mime=True)
85
86
  finally:
86
87
  if tmp_file and not is_tabular:
87
88
  os.remove(tmp_file.name)
88
- await update_check(check_id, {
89
- "checksum": dl_analysis.get("analysis:checksum"),
90
- "analysis_error": dl_analysis.get("analysis:error"),
91
- "filesize": dl_analysis.get("analysis:content-length"),
92
- "mime_type": dl_analysis.get("analysis:mime-type"),
93
- })
89
+ await update_check(
90
+ check_id,
91
+ {
92
+ "checksum": dl_analysis.get("analysis:checksum"),
93
+ "analysis_error": dl_analysis.get("analysis:error"),
94
+ "filesize": dl_analysis.get("analysis:content-length"),
95
+ "mime_type": dl_analysis.get("analysis:mime-type"),
96
+ },
97
+ )
94
98
 
95
99
  if change_status == Change.HAS_CHANGED:
96
100
  await store_last_modified_date(change_payload or {}, resource_id, check_id)
@@ -119,11 +123,14 @@ async def store_last_modified_date(change_analysis, resource_id, check_id) -> No
119
123
  async with pool.acquire() as conn:
120
124
  await conn.execute(
121
125
  "UPDATE checks SET detected_last_modified_at = $1 WHERE id = $2",
122
- last_modified, check_id
126
+ last_modified,
127
+ check_id,
123
128
  )
124
129
 
125
130
 
126
- async def detect_resource_change_from_checksum(resource_id, new_checksum) -> Tuple[Change, Union[dict, None]]:
131
+ async def detect_resource_change_from_checksum(
132
+ resource_id, new_checksum
133
+ ) -> Tuple[Change, Union[dict, None]]:
127
134
  """
128
135
  Checks if resource checksum has changed over time
129
136
  Returns a tuple with a Change status and an optional payload:
@@ -150,7 +157,9 @@ async def detect_resource_change_from_checksum(resource_id, new_checksum) -> Tup
150
157
  return Change.NO_GUESS, None
151
158
 
152
159
 
153
- async def detect_resource_change_from_last_modified_header(data: dict) -> Tuple[Change, Union[dict, None]]:
160
+ async def detect_resource_change_from_last_modified_header(
161
+ data: dict,
162
+ ) -> Tuple[Change, Union[dict, None]]:
154
163
  # last modified header check
155
164
 
156
165
  if len(data) == 1 and data[0]["last_modified"]:
@@ -172,7 +181,9 @@ async def detect_resource_change_from_last_modified_header(data: dict) -> Tuple[
172
181
  return Change.HAS_NOT_CHANGED, None
173
182
 
174
183
 
175
- async def detect_resource_change_from_content_length_header(data: dict) -> Tuple[Change, Union[dict, None]]:
184
+ async def detect_resource_change_from_content_length_header(
185
+ data: dict,
186
+ ) -> Tuple[Change, Union[dict, None]]:
176
187
  # content-length variation between current and last check
177
188
  if len(data) <= 1 or not data[0]["content_length"]:
178
189
  return Change.NO_GUESS, None
@@ -185,7 +196,9 @@ async def detect_resource_change_from_content_length_header(data: dict) -> Tuple
185
196
  return Change.HAS_NOT_CHANGED, None
186
197
 
187
198
 
188
- async def detect_resource_change_on_early_hints(resource_id: str) -> Tuple[Change, Union[dict, None]]:
199
+ async def detect_resource_change_on_early_hints(
200
+ resource_id: str,
201
+ ) -> Tuple[Change, Union[dict, None]]:
189
202
  """
190
203
  Try to guess if a resource has been modified from harvest and headers in check data:
191
204
  - last-modified header value if it can be found and parsed
@@ -223,7 +236,10 @@ async def detect_resource_change_on_early_hints(resource_id: str) -> Tuple[Chang
223
236
  return change_status, change_payload
224
237
 
225
238
  # if not, let's see if we can infer a modifification date from last-modified headers
226
- change_status, change_payload = await detect_resource_change_from_last_modified_header(data)
239
+ (
240
+ change_status,
241
+ change_payload,
242
+ ) = await detect_resource_change_from_last_modified_header(data)
227
243
  if change_status != Change.NO_GUESS:
228
244
  return change_status, change_payload
229
245
 
@@ -231,7 +247,9 @@ async def detect_resource_change_on_early_hints(resource_id: str) -> Tuple[Chang
231
247
  return await detect_resource_change_from_content_length_header(data)
232
248
 
233
249
 
234
- async def detect_resource_change_from_harvest(checks_data: dict, resource_id: str) -> Tuple[Change, Union[dict, None]]:
250
+ async def detect_resource_change_from_harvest(
251
+ checks_data: dict, resource_id: str
252
+ ) -> Tuple[Change, Union[dict, None]]:
235
253
  """
236
254
  Checks if resource has a harvest.modified_at
237
255
  Returns a tuple with a Change status and an optional payload: