desidata 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.
- desidata-0.1.0/LICENSE +21 -0
- desidata-0.1.0/PKG-INFO +165 -0
- desidata-0.1.0/README.md +132 -0
- desidata-0.1.0/pyproject.toml +45 -0
- desidata-0.1.0/setup.cfg +4 -0
- desidata-0.1.0/src/desidata/__init__.py +113 -0
- desidata-0.1.0/src/desidata/__main__.py +5 -0
- desidata-0.1.0/src/desidata/cli.py +115 -0
- desidata-0.1.0/src/desidata/client.py +175 -0
- desidata-0.1.0/src/desidata.egg-info/PKG-INFO +165 -0
- desidata-0.1.0/src/desidata.egg-info/SOURCES.txt +16 -0
- desidata-0.1.0/src/desidata.egg-info/dependency_links.txt +1 -0
- desidata-0.1.0/src/desidata.egg-info/entry_points.txt +2 -0
- desidata-0.1.0/src/desidata.egg-info/requires.txt +6 -0
- desidata-0.1.0/src/desidata.egg-info/top_level.txt +1 -0
- desidata-0.1.0/tests/test_cli.py +82 -0
- desidata-0.1.0/tests/test_client.py +136 -0
- desidata-0.1.0/tests/test_live.py +57 -0
desidata-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DesiData (https://www.desidata.in)
|
|
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.
|
desidata-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: desidata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load Indian datasets from desidata.in in one line — search the catalogue, download CSVs, get pandas DataFrames.
|
|
5
|
+
Author-email: DesiData <hello@desidata.in>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.desidata.in
|
|
8
|
+
Project-URL: Documentation, https://www.desidata.in
|
|
9
|
+
Project-URL: Repository, https://github.com/krishnakaushik195/desidata-py
|
|
10
|
+
Project-URL: Issues, https://github.com/krishnakaushik195/desidata-py/issues
|
|
11
|
+
Keywords: india,indian,datasets,data,pandas,open-data,csv
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: Education
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: pandas
|
|
29
|
+
Requires-Dist: pandas>=1.3; extra == "pandas"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# desidata
|
|
35
|
+
|
|
36
|
+
**Load Indian datasets from [desidata.in](https://www.desidata.in) in one line.**
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import desidata
|
|
40
|
+
|
|
41
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That's it — no URL copying, no sign-in, no API key. You get a pandas
|
|
45
|
+
DataFrame of cleaned, India-focused public data: economy, agriculture,
|
|
46
|
+
health, education, transport, demographics and more.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install desidata
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The core package has **zero dependencies**, so it installs instantly.
|
|
55
|
+
`load()` needs pandas (almost always already installed) and will tell you
|
|
56
|
+
exactly what to run if it is missing:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install desidata pandas
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import desidata
|
|
66
|
+
|
|
67
|
+
# Search the catalogue from Python
|
|
68
|
+
results = desidata.search("census")
|
|
69
|
+
for row in results:
|
|
70
|
+
print(row["slug"], row["category"], row["rows"])
|
|
71
|
+
|
|
72
|
+
# Browse everything, or one category
|
|
73
|
+
all_datasets = desidata.catalog()
|
|
74
|
+
economy = desidata.catalog(category="Economy")
|
|
75
|
+
|
|
76
|
+
# Metadata for one dataset
|
|
77
|
+
info = desidata.info("gender-policy-of-nabard-question-and-answer-dataset")
|
|
78
|
+
print(info["name"], info["size"], info["downloads"])
|
|
79
|
+
|
|
80
|
+
# Load as a DataFrame (kwargs pass through to pandas.read_csv)
|
|
81
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
82
|
+
df = desidata.load("some-dataset", dtype={"year": int}, parse_dates=["date"])
|
|
83
|
+
|
|
84
|
+
# Raw bytes, or save straight to disk
|
|
85
|
+
data = desidata.download("some-dataset") # bytes
|
|
86
|
+
path = desidata.download("some-dataset", "a.csv") # writes the file
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Full dataset URLs work anywhere a slug does — paste straight from the browser:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
desidata.load("https://www.desidata.in/datasets/gender-policy-of-nabard-question-and-answer-dataset")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Caching
|
|
96
|
+
|
|
97
|
+
Loads are cached in `~/.desidata/cache` for 24 hours, so re-loading in a
|
|
98
|
+
notebook (or in a classroom on weak wifi) is instant and offline-safe.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
desidata.load("some-dataset", refresh=True) # bypass the cache
|
|
102
|
+
desidata.clear_cache() # wipe all cached files
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Set `DESIDATA_CACHE_DIR` to move the cache somewhere else.
|
|
106
|
+
|
|
107
|
+
## Command line
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
desidata search nabard
|
|
111
|
+
desidata info gender-policy-of-nabard-question-and-answer-dataset
|
|
112
|
+
desidata catalog --category Agriculture
|
|
113
|
+
desidata download gender-policy-of-nabard-question-and-answer-dataset -o nabard.csv
|
|
114
|
+
desidata clear-cache
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Error handling
|
|
118
|
+
|
|
119
|
+
Every error the package raises subclasses `desidata.DesiDataError`:
|
|
120
|
+
|
|
121
|
+
| Exception | Meaning |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `DesiDataNotFound` | That dataset doesn't exist (HTTP 404) |
|
|
124
|
+
| `DesiDataConnectionError` | Network/DNS/timeout failure |
|
|
125
|
+
| `DesiDataServerError` | desidata.in returned an unexpected error |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
try:
|
|
129
|
+
df = desidata.load("some-slug")
|
|
130
|
+
except desidata.DesiDataNotFound:
|
|
131
|
+
print("Check the slug with desidata.search(...)")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git clone https://github.com/krishnakaushik195/desidata-py
|
|
138
|
+
cd desidata-py
|
|
139
|
+
pip install -e .[dev]
|
|
140
|
+
pytest # offline unit tests
|
|
141
|
+
RUN_LIVE=1 pytest tests/test_live.py -v # integration tests against the real site
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Environment overrides used by tests and local development:
|
|
145
|
+
|
|
146
|
+
| Variable | Purpose |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `DESIDATA_BASE_URL` | Point the client at a local/dev server |
|
|
149
|
+
| `DESIDATA_CACHE_DIR` | Relocate the cache |
|
|
150
|
+
| `DESIDATA_TIMEOUT` | Default request timeout in seconds |
|
|
151
|
+
|
|
152
|
+
## Releasing
|
|
153
|
+
|
|
154
|
+
Maintainers only:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install --upgrade build twine
|
|
158
|
+
python -m build
|
|
159
|
+
twine upload dist/*
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Licence
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](LICENSE). The data itself belongs to its sources; each
|
|
165
|
+
dataset page on [desidata.in](https://www.desidata.in) lists its source and licence.
|
desidata-0.1.0/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# desidata
|
|
2
|
+
|
|
3
|
+
**Load Indian datasets from [desidata.in](https://www.desidata.in) in one line.**
|
|
4
|
+
|
|
5
|
+
```python
|
|
6
|
+
import desidata
|
|
7
|
+
|
|
8
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
That's it — no URL copying, no sign-in, no API key. You get a pandas
|
|
12
|
+
DataFrame of cleaned, India-focused public data: economy, agriculture,
|
|
13
|
+
health, education, transport, demographics and more.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install desidata
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The core package has **zero dependencies**, so it installs instantly.
|
|
22
|
+
`load()` needs pandas (almost always already installed) and will tell you
|
|
23
|
+
exactly what to run if it is missing:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
pip install desidata pandas
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```python
|
|
32
|
+
import desidata
|
|
33
|
+
|
|
34
|
+
# Search the catalogue from Python
|
|
35
|
+
results = desidata.search("census")
|
|
36
|
+
for row in results:
|
|
37
|
+
print(row["slug"], row["category"], row["rows"])
|
|
38
|
+
|
|
39
|
+
# Browse everything, or one category
|
|
40
|
+
all_datasets = desidata.catalog()
|
|
41
|
+
economy = desidata.catalog(category="Economy")
|
|
42
|
+
|
|
43
|
+
# Metadata for one dataset
|
|
44
|
+
info = desidata.info("gender-policy-of-nabard-question-and-answer-dataset")
|
|
45
|
+
print(info["name"], info["size"], info["downloads"])
|
|
46
|
+
|
|
47
|
+
# Load as a DataFrame (kwargs pass through to pandas.read_csv)
|
|
48
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
49
|
+
df = desidata.load("some-dataset", dtype={"year": int}, parse_dates=["date"])
|
|
50
|
+
|
|
51
|
+
# Raw bytes, or save straight to disk
|
|
52
|
+
data = desidata.download("some-dataset") # bytes
|
|
53
|
+
path = desidata.download("some-dataset", "a.csv") # writes the file
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Full dataset URLs work anywhere a slug does — paste straight from the browser:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
desidata.load("https://www.desidata.in/datasets/gender-policy-of-nabard-question-and-answer-dataset")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Caching
|
|
63
|
+
|
|
64
|
+
Loads are cached in `~/.desidata/cache` for 24 hours, so re-loading in a
|
|
65
|
+
notebook (or in a classroom on weak wifi) is instant and offline-safe.
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
desidata.load("some-dataset", refresh=True) # bypass the cache
|
|
69
|
+
desidata.clear_cache() # wipe all cached files
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Set `DESIDATA_CACHE_DIR` to move the cache somewhere else.
|
|
73
|
+
|
|
74
|
+
## Command line
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
desidata search nabard
|
|
78
|
+
desidata info gender-policy-of-nabard-question-and-answer-dataset
|
|
79
|
+
desidata catalog --category Agriculture
|
|
80
|
+
desidata download gender-policy-of-nabard-question-and-answer-dataset -o nabard.csv
|
|
81
|
+
desidata clear-cache
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Error handling
|
|
85
|
+
|
|
86
|
+
Every error the package raises subclasses `desidata.DesiDataError`:
|
|
87
|
+
|
|
88
|
+
| Exception | Meaning |
|
|
89
|
+
| --- | --- |
|
|
90
|
+
| `DesiDataNotFound` | That dataset doesn't exist (HTTP 404) |
|
|
91
|
+
| `DesiDataConnectionError` | Network/DNS/timeout failure |
|
|
92
|
+
| `DesiDataServerError` | desidata.in returned an unexpected error |
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
try:
|
|
96
|
+
df = desidata.load("some-slug")
|
|
97
|
+
except desidata.DesiDataNotFound:
|
|
98
|
+
print("Check the slug with desidata.search(...)")
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Development
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
git clone https://github.com/krishnakaushik195/desidata-py
|
|
105
|
+
cd desidata-py
|
|
106
|
+
pip install -e .[dev]
|
|
107
|
+
pytest # offline unit tests
|
|
108
|
+
RUN_LIVE=1 pytest tests/test_live.py -v # integration tests against the real site
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Environment overrides used by tests and local development:
|
|
112
|
+
|
|
113
|
+
| Variable | Purpose |
|
|
114
|
+
| --- | --- |
|
|
115
|
+
| `DESIDATA_BASE_URL` | Point the client at a local/dev server |
|
|
116
|
+
| `DESIDATA_CACHE_DIR` | Relocate the cache |
|
|
117
|
+
| `DESIDATA_TIMEOUT` | Default request timeout in seconds |
|
|
118
|
+
|
|
119
|
+
## Releasing
|
|
120
|
+
|
|
121
|
+
Maintainers only:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
pip install --upgrade build twine
|
|
125
|
+
python -m build
|
|
126
|
+
twine upload dist/*
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
## Licence
|
|
130
|
+
|
|
131
|
+
MIT — see [LICENSE](LICENSE). The data itself belongs to its sources; each
|
|
132
|
+
dataset page on [desidata.in](https://www.desidata.in) lists its source and licence.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "desidata"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Load Indian datasets from desidata.in in one line — search the catalogue, download CSVs, get pandas DataFrames."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "DesiData", email = "hello@desidata.in" }]
|
|
13
|
+
keywords = ["india", "indian", "datasets", "data", "pandas", "open-data", "csv"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Science/Research",
|
|
18
|
+
"Intended Audience :: Education",
|
|
19
|
+
"License :: OSI Approved :: MIT License",
|
|
20
|
+
"Operating System :: OS Independent",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.9",
|
|
23
|
+
"Programming Language :: Python :: 3.10",
|
|
24
|
+
"Programming Language :: Python :: 3.11",
|
|
25
|
+
"Programming Language :: Python :: 3.12",
|
|
26
|
+
"Programming Language :: Python :: 3.13",
|
|
27
|
+
"Topic :: Scientific/Engineering",
|
|
28
|
+
]
|
|
29
|
+
dependencies = []
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Homepage = "https://www.desidata.in"
|
|
33
|
+
Documentation = "https://www.desidata.in"
|
|
34
|
+
Repository = "https://github.com/krishnakaushik195/desidata-py"
|
|
35
|
+
Issues = "https://github.com/krishnakaushik195/desidata-py/issues"
|
|
36
|
+
|
|
37
|
+
[project.optional-dependencies]
|
|
38
|
+
pandas = ["pandas>=1.3"]
|
|
39
|
+
dev = ["pytest>=7"]
|
|
40
|
+
|
|
41
|
+
[project.scripts]
|
|
42
|
+
desidata = "desidata.cli:main"
|
|
43
|
+
|
|
44
|
+
[tool.setuptools.packages.find]
|
|
45
|
+
where = ["src"]
|
desidata-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""desidata — load Indian datasets from desidata.in in one line.
|
|
2
|
+
|
|
3
|
+
import desidata
|
|
4
|
+
|
|
5
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
6
|
+
results = desidata.search("census")
|
|
7
|
+
|
|
8
|
+
The core install is dependency-free; `load()` needs pandas and raises a
|
|
9
|
+
helpful error telling you to install it if it is missing.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, Dict, List, Optional, Union
|
|
16
|
+
|
|
17
|
+
from .client import ( # noqa: F401 (re-exports are the public API)
|
|
18
|
+
DEFAULT_BASE_URL,
|
|
19
|
+
__version__,
|
|
20
|
+
base_url,
|
|
21
|
+
cache_directory,
|
|
22
|
+
clear_cache,
|
|
23
|
+
fetch_catalog,
|
|
24
|
+
fetch_csv_bytes,
|
|
25
|
+
fetch_info,
|
|
26
|
+
search_datasets,
|
|
27
|
+
)
|
|
28
|
+
from .client import ( # noqa: F401
|
|
29
|
+
DesiDataConnectionError,
|
|
30
|
+
DesiDataError,
|
|
31
|
+
DesiDataNotFound,
|
|
32
|
+
DesiDataServerError,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
"load",
|
|
37
|
+
"download",
|
|
38
|
+
"info",
|
|
39
|
+
"search",
|
|
40
|
+
"catalog",
|
|
41
|
+
"clear_cache",
|
|
42
|
+
"base_url",
|
|
43
|
+
"cache_directory",
|
|
44
|
+
"DEFAULT_BASE_URL",
|
|
45
|
+
"__version__",
|
|
46
|
+
"DesiDataError",
|
|
47
|
+
"DesiDataConnectionError",
|
|
48
|
+
"DesiDataNotFound",
|
|
49
|
+
"DesiDataServerError",
|
|
50
|
+
]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(
|
|
54
|
+
slug: str,
|
|
55
|
+
refresh: bool = False,
|
|
56
|
+
timeout: Optional[float] = None,
|
|
57
|
+
**pandas_kwargs: Any,
|
|
58
|
+
):
|
|
59
|
+
"""Load a dataset as a pandas DataFrame.
|
|
60
|
+
|
|
61
|
+
`slug` may be a bare slug or a full desidata.in dataset URL. Extra keyword
|
|
62
|
+
arguments pass straight through to `pandas.read_csv` (e.g. `dtype=`,
|
|
63
|
+
`parse_dates=`). Loads are cached for 24 hours; pass `refresh=True` to
|
|
64
|
+
re-download.
|
|
65
|
+
"""
|
|
66
|
+
try:
|
|
67
|
+
import pandas
|
|
68
|
+
except ImportError as error: # pragma: no cover - depends on environment
|
|
69
|
+
raise ImportError(
|
|
70
|
+
"desidata.load() needs pandas. Install it with: pip install pandas"
|
|
71
|
+
) from error
|
|
72
|
+
|
|
73
|
+
data = fetch_csv_bytes(slug, refresh=refresh, timeout=timeout)
|
|
74
|
+
return pandas.read_csv(io.BytesIO(data), **pandas_kwargs)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def download(
|
|
78
|
+
slug: str,
|
|
79
|
+
path: Optional[Union[str, Path]] = None,
|
|
80
|
+
refresh: bool = False,
|
|
81
|
+
timeout: Optional[float] = None,
|
|
82
|
+
) -> Union[bytes, Path]:
|
|
83
|
+
"""Download the raw CSV.
|
|
84
|
+
|
|
85
|
+
With `path`, writes the file there and returns the Path; without it,
|
|
86
|
+
returns the CSV bytes. Uses the same 24-hour cache as `load()`.
|
|
87
|
+
"""
|
|
88
|
+
data = fetch_csv_bytes(slug, refresh=refresh, timeout=timeout)
|
|
89
|
+
if path is None:
|
|
90
|
+
return data
|
|
91
|
+
destination = Path(path)
|
|
92
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
destination.write_bytes(data)
|
|
94
|
+
return destination
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def info(slug: str, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
98
|
+
"""Metadata for one dataset: name, description, format, size, downloads, tags."""
|
|
99
|
+
return fetch_info(slug, timeout=timeout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def search(query: str, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
|
|
103
|
+
"""Search dataset titles and descriptions."""
|
|
104
|
+
return search_datasets(query, timeout=timeout)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def catalog(
|
|
108
|
+
category: Optional[str] = None,
|
|
109
|
+
language: Optional[str] = None,
|
|
110
|
+
timeout: Optional[float] = None,
|
|
111
|
+
) -> List[Dict[str, Any]]:
|
|
112
|
+
"""List every published dataset, optionally filtered."""
|
|
113
|
+
return fetch_catalog(category=category, language=language, timeout=timeout)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Command line interface: desidata <command> [args].
|
|
2
|
+
|
|
3
|
+
Zero dependencies (argparse), so the CLI ships with the base install.
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import sys
|
|
9
|
+
from typing import List, Optional
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .client import (
|
|
13
|
+
DesiDataError,
|
|
14
|
+
clear_cache,
|
|
15
|
+
fetch_catalog,
|
|
16
|
+
fetch_csv_bytes,
|
|
17
|
+
fetch_info,
|
|
18
|
+
search_datasets,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Windows consoles default to legacy code pages; dataset titles contain ₹ etc.
|
|
22
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
23
|
+
if hasattr(_stream, "reconfigure"):
|
|
24
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _print_table(rows: List[dict]) -> None:
|
|
28
|
+
if not rows:
|
|
29
|
+
print("No datasets found.")
|
|
30
|
+
return
|
|
31
|
+
slug_width = min(max(len(row["slug"]) for row in rows) + 2, 44)
|
|
32
|
+
category_width = min(max(len(str(row.get("category", ""))) for row in rows) + 2, 14)
|
|
33
|
+
print(f"{'slug':<{slug_width}}{'category':<{category_width}}{'rows':>7} title")
|
|
34
|
+
for row in rows:
|
|
35
|
+
slug = row["slug"]
|
|
36
|
+
if len(slug) > slug_width - 1:
|
|
37
|
+
slug = slug[: slug_width - 4] + "..."
|
|
38
|
+
category = str(row.get("category", ""))
|
|
39
|
+
if len(category) > category_width - 1:
|
|
40
|
+
category = category[: category_width - 1]
|
|
41
|
+
title = str(row.get("title", "")).replace("\n", " ")
|
|
42
|
+
print(f"{slug:<{slug_width}}{category:<{category_width}}{row.get('rows', 0):>7} {title}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _print_info(metadata: dict) -> None:
|
|
46
|
+
order = ["slug", "name", "description", "category", "language", "format", "size", "downloads", "tags", "download_url"]
|
|
47
|
+
for key in order:
|
|
48
|
+
if key in metadata:
|
|
49
|
+
value = metadata[key]
|
|
50
|
+
if isinstance(value, list):
|
|
51
|
+
value = ", ".join(str(item) for item in value)
|
|
52
|
+
print(f"{key:>14}: {value}")
|
|
53
|
+
for key, value in metadata.items():
|
|
54
|
+
if key not in order:
|
|
55
|
+
print(f"{key:>14}: {value}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
59
|
+
parser = argparse.ArgumentParser(
|
|
60
|
+
prog="desidata",
|
|
61
|
+
description="Load Indian datasets from desidata.in (https://www.desidata.in).",
|
|
62
|
+
)
|
|
63
|
+
parser.add_argument("--version", action="version", version=f"desidata {__version__}")
|
|
64
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
65
|
+
|
|
66
|
+
search_parser = subparsers.add_parser("search", help="Search dataset titles and descriptions")
|
|
67
|
+
search_parser.add_argument("query")
|
|
68
|
+
|
|
69
|
+
info_parser = subparsers.add_parser("info", help="Show metadata for one dataset")
|
|
70
|
+
info_parser.add_argument("slug", help="Dataset slug or full desidata.in URL")
|
|
71
|
+
|
|
72
|
+
catalog_parser = subparsers.add_parser("catalog", help="List every published dataset")
|
|
73
|
+
catalog_parser.add_argument("--category", default=None)
|
|
74
|
+
|
|
75
|
+
download_parser = subparsers.add_parser("download", help="Download a dataset CSV")
|
|
76
|
+
download_parser.add_argument("slug", help="Dataset slug or full desidata.in URL")
|
|
77
|
+
download_parser.add_argument("-o", "--output", default=None, help="Output file (default: <slug>.csv)")
|
|
78
|
+
download_parser.add_argument("--refresh", action="store_true", help="Bypass the local cache")
|
|
79
|
+
|
|
80
|
+
subparsers.add_parser("clear-cache", help="Delete cached CSVs (~/.desidata/cache)")
|
|
81
|
+
|
|
82
|
+
return parser
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
86
|
+
args = _build_parser().parse_args(argv)
|
|
87
|
+
|
|
88
|
+
try:
|
|
89
|
+
if args.command == "search":
|
|
90
|
+
_print_table(search_datasets(args.query))
|
|
91
|
+
elif args.command == "info":
|
|
92
|
+
_print_info(fetch_info(args.slug))
|
|
93
|
+
elif args.command == "catalog":
|
|
94
|
+
_print_table(fetch_catalog(category=args.category))
|
|
95
|
+
elif args.command == "download":
|
|
96
|
+
data = fetch_csv_bytes(args.slug, refresh=args.refresh)
|
|
97
|
+
output = args.output or f"{args.slug.rsplit('/', 1)[-1].lower()}.csv"
|
|
98
|
+
with open(output, "wb") as handle:
|
|
99
|
+
handle.write(data)
|
|
100
|
+
print(f"Saved {len(data):,} bytes to {output}")
|
|
101
|
+
elif args.command == "clear-cache":
|
|
102
|
+
removed = clear_cache()
|
|
103
|
+
print(f"Removed {removed} cached file(s).")
|
|
104
|
+
except DesiDataError as error:
|
|
105
|
+
print(f"error: {error}", file=sys.stderr)
|
|
106
|
+
return 1
|
|
107
|
+
except ValueError as error:
|
|
108
|
+
print(f"error: {error}", file=sys.stderr)
|
|
109
|
+
return 1
|
|
110
|
+
|
|
111
|
+
return 0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__": # pragma: no cover
|
|
115
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""HTTP and cache layer. Everything network-facing lives here.
|
|
2
|
+
|
|
3
|
+
The package is deliberately dependency-free: only the standard library is
|
|
4
|
+
used, so `pip install desidata` works instantly everywhere. pandas stays an
|
|
5
|
+
optional import in the public API layer.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import time
|
|
13
|
+
import urllib.error
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List, Optional, Union
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0"
|
|
20
|
+
|
|
21
|
+
DEFAULT_BASE_URL = "https://www.desidata.in"
|
|
22
|
+
DEFAULT_TIMEOUT = 60.0
|
|
23
|
+
CACHE_MAX_AGE_SECONDS = 24 * 60 * 60
|
|
24
|
+
|
|
25
|
+
_USER_AGENT = f"desidata-py/{__version__} (+https://www.desidata.in)"
|
|
26
|
+
_SLUG_PATTERN = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DesiDataError(Exception):
|
|
30
|
+
"""Base class for every error raised by this package."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DesiDataConnectionError(DesiDataError):
|
|
34
|
+
"""desidata.in could not be reached (network, DNS or timeout failure)."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class DesiDataNotFound(DesiDataError):
|
|
38
|
+
"""The requested dataset does not exist (HTTP 404)."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class DesiDataServerError(DesiDataError):
|
|
42
|
+
"""The server responded with an unexpected error."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def base_url() -> str:
|
|
46
|
+
"""API root, overridable with DESIDATA_BASE_URL (used by tests and dev)."""
|
|
47
|
+
return (os.environ.get("DESIDATA_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def cache_directory() -> Path:
|
|
51
|
+
"""Cache location, overridable with DESIDATA_CACHE_DIR."""
|
|
52
|
+
directory = Path(os.environ.get("DESIDATA_CACHE_DIR") or Path.home() / ".desidata" / "cache")
|
|
53
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
54
|
+
return directory
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def clear_cache() -> int:
|
|
58
|
+
"""Deletes every cached CSV. Returns the number of files removed."""
|
|
59
|
+
removed = 0
|
|
60
|
+
for path in cache_directory().glob("*.csv"):
|
|
61
|
+
try:
|
|
62
|
+
path.unlink()
|
|
63
|
+
removed += 1
|
|
64
|
+
except OSError:
|
|
65
|
+
pass
|
|
66
|
+
return removed
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _timeout(timeout: Optional[float]) -> float:
|
|
70
|
+
if timeout is not None:
|
|
71
|
+
return float(timeout)
|
|
72
|
+
env = os.environ.get("DESIDATA_TIMEOUT")
|
|
73
|
+
return float(env) if env else DEFAULT_TIMEOUT
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _normalise_slug(slug: str) -> str:
|
|
77
|
+
"""Accepts a slug, a dataset URL or a path and returns the bare slug.
|
|
78
|
+
|
|
79
|
+
Students paste full URLs constantly, so 'https://www.desidata.in/datasets/
|
|
80
|
+
crop-prices' and 'crop-prices' both work.
|
|
81
|
+
"""
|
|
82
|
+
value = slug.strip()
|
|
83
|
+
if ".." in value:
|
|
84
|
+
raise ValueError(f"Invalid dataset slug: {slug!r}.")
|
|
85
|
+
if "://" in value:
|
|
86
|
+
value = value.split("://", 1)[1]
|
|
87
|
+
if "/" in value:
|
|
88
|
+
value = value.rstrip("/").rsplit("/", 1)[-1]
|
|
89
|
+
value = value.strip().lower()
|
|
90
|
+
if not _SLUG_PATTERN.match(value):
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Invalid dataset slug: {slug!r}. Slugs look like 'crop-prices-2021' "
|
|
93
|
+
"(lowercase letters, digits and dashes)."
|
|
94
|
+
)
|
|
95
|
+
return value
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _open(url: str, timeout_value: float, accept: str) -> bytes:
|
|
99
|
+
request = urllib.request.Request(url, headers={"User-Agent": _USER_AGENT, "Accept": accept})
|
|
100
|
+
try:
|
|
101
|
+
with urllib.request.urlopen(request, timeout=timeout_value) as response:
|
|
102
|
+
return response.read()
|
|
103
|
+
except urllib.error.HTTPError as error:
|
|
104
|
+
if error.code == 404:
|
|
105
|
+
raise DesiDataNotFound(f"Not found: {url}") from error
|
|
106
|
+
raise DesiDataServerError(f"desidata.in returned HTTP {error.code} for {url}") from error
|
|
107
|
+
except urllib.error.URLError as error:
|
|
108
|
+
raise DesiDataConnectionError(f"Could not reach {url}: {error.reason}") from error
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get_json(path: str, timeout: Optional[float]) -> Dict[str, Any]:
|
|
112
|
+
payload = _open(f"{base_url()}{path}", _timeout(timeout), "application/json")
|
|
113
|
+
import json
|
|
114
|
+
|
|
115
|
+
return json.loads(payload.decode("utf-8"))
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def fetch_info(slug: str, timeout: Optional[float] = None) -> Dict[str, Any]:
|
|
119
|
+
"""Metadata for one dataset: name, description, format, size, downloads, tags."""
|
|
120
|
+
return _get_json(f"/api/datasets/{_normalise_slug(slug)}", timeout)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def fetch_catalog(
|
|
124
|
+
category: Optional[str] = None,
|
|
125
|
+
language: Optional[str] = None,
|
|
126
|
+
timeout: Optional[float] = None,
|
|
127
|
+
) -> List[Dict[str, Any]]:
|
|
128
|
+
"""Every published dataset, optionally filtered by category and language."""
|
|
129
|
+
params = []
|
|
130
|
+
if category:
|
|
131
|
+
params.append(f"category={urllib.parse.quote(category)}")
|
|
132
|
+
if language:
|
|
133
|
+
params.append(f"language={urllib.parse.quote(language)}")
|
|
134
|
+
path = "/api/datasets" + ("?" + "&".join(params) if params else "")
|
|
135
|
+
response = _get_json(path, timeout)
|
|
136
|
+
return response.get("datasets", [])
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def search_datasets(query: str, timeout: Optional[float] = None) -> List[Dict[str, Any]]:
|
|
140
|
+
"""Catalogue search over titles and descriptions."""
|
|
141
|
+
params = f"?query={urllib.parse.quote(query)}"
|
|
142
|
+
response = _get_json(f"/api/datasets{params}", timeout)
|
|
143
|
+
return response.get("datasets", [])
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def fetch_csv_bytes(
|
|
147
|
+
slug: str,
|
|
148
|
+
refresh: bool = False,
|
|
149
|
+
timeout: Optional[float] = None,
|
|
150
|
+
) -> bytes:
|
|
151
|
+
"""Raw CSV bytes for a dataset, served from the local cache when fresh.
|
|
152
|
+
|
|
153
|
+
The cache lives in ~/.desidata/cache and entries expire after 24 hours.
|
|
154
|
+
`refresh=True` bypasses and replaces the cached copy.
|
|
155
|
+
"""
|
|
156
|
+
normalised = _normalise_slug(slug)
|
|
157
|
+
path = cache_directory() / f"{normalised}.csv"
|
|
158
|
+
|
|
159
|
+
if not refresh and path.exists():
|
|
160
|
+
age = time.time() - path.stat().st_mtime
|
|
161
|
+
if age < CACHE_MAX_AGE_SECONDS:
|
|
162
|
+
return path.read_bytes()
|
|
163
|
+
|
|
164
|
+
data = _open(f"{base_url()}/api/datasets/{normalised}/download", _timeout(timeout), "text/csv")
|
|
165
|
+
|
|
166
|
+
# Caching is best-effort: a read-only home directory must not break loads.
|
|
167
|
+
try:
|
|
168
|
+
fd, temporary = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
|
169
|
+
with os.fdopen(fd, "wb") as handle:
|
|
170
|
+
handle.write(data)
|
|
171
|
+
os.replace(temporary, path)
|
|
172
|
+
except OSError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
return data
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: desidata
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Load Indian datasets from desidata.in in one line — search the catalogue, download CSVs, get pandas DataFrames.
|
|
5
|
+
Author-email: DesiData <hello@desidata.in>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://www.desidata.in
|
|
8
|
+
Project-URL: Documentation, https://www.desidata.in
|
|
9
|
+
Project-URL: Repository, https://github.com/krishnakaushik195/desidata-py
|
|
10
|
+
Project-URL: Issues, https://github.com/krishnakaushik195/desidata-py/issues
|
|
11
|
+
Keywords: india,indian,datasets,data,pandas,open-data,csv
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Science/Research
|
|
15
|
+
Classifier: Intended Audience :: Education
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Operating System :: OS Independent
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Scientific/Engineering
|
|
25
|
+
Requires-Python: >=3.9
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
License-File: LICENSE
|
|
28
|
+
Provides-Extra: pandas
|
|
29
|
+
Requires-Dist: pandas>=1.3; extra == "pandas"
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
32
|
+
Dynamic: license-file
|
|
33
|
+
|
|
34
|
+
# desidata
|
|
35
|
+
|
|
36
|
+
**Load Indian datasets from [desidata.in](https://www.desidata.in) in one line.**
|
|
37
|
+
|
|
38
|
+
```python
|
|
39
|
+
import desidata
|
|
40
|
+
|
|
41
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That's it — no URL copying, no sign-in, no API key. You get a pandas
|
|
45
|
+
DataFrame of cleaned, India-focused public data: economy, agriculture,
|
|
46
|
+
health, education, transport, demographics and more.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install desidata
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
The core package has **zero dependencies**, so it installs instantly.
|
|
55
|
+
`load()` needs pandas (almost always already installed) and will tell you
|
|
56
|
+
exactly what to run if it is missing:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install desidata pandas
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Usage
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
import desidata
|
|
66
|
+
|
|
67
|
+
# Search the catalogue from Python
|
|
68
|
+
results = desidata.search("census")
|
|
69
|
+
for row in results:
|
|
70
|
+
print(row["slug"], row["category"], row["rows"])
|
|
71
|
+
|
|
72
|
+
# Browse everything, or one category
|
|
73
|
+
all_datasets = desidata.catalog()
|
|
74
|
+
economy = desidata.catalog(category="Economy")
|
|
75
|
+
|
|
76
|
+
# Metadata for one dataset
|
|
77
|
+
info = desidata.info("gender-policy-of-nabard-question-and-answer-dataset")
|
|
78
|
+
print(info["name"], info["size"], info["downloads"])
|
|
79
|
+
|
|
80
|
+
# Load as a DataFrame (kwargs pass through to pandas.read_csv)
|
|
81
|
+
df = desidata.load("gender-policy-of-nabard-question-and-answer-dataset")
|
|
82
|
+
df = desidata.load("some-dataset", dtype={"year": int}, parse_dates=["date"])
|
|
83
|
+
|
|
84
|
+
# Raw bytes, or save straight to disk
|
|
85
|
+
data = desidata.download("some-dataset") # bytes
|
|
86
|
+
path = desidata.download("some-dataset", "a.csv") # writes the file
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Full dataset URLs work anywhere a slug does — paste straight from the browser:
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
desidata.load("https://www.desidata.in/datasets/gender-policy-of-nabard-question-and-answer-dataset")
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Caching
|
|
96
|
+
|
|
97
|
+
Loads are cached in `~/.desidata/cache` for 24 hours, so re-loading in a
|
|
98
|
+
notebook (or in a classroom on weak wifi) is instant and offline-safe.
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
desidata.load("some-dataset", refresh=True) # bypass the cache
|
|
102
|
+
desidata.clear_cache() # wipe all cached files
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Set `DESIDATA_CACHE_DIR` to move the cache somewhere else.
|
|
106
|
+
|
|
107
|
+
## Command line
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
desidata search nabard
|
|
111
|
+
desidata info gender-policy-of-nabard-question-and-answer-dataset
|
|
112
|
+
desidata catalog --category Agriculture
|
|
113
|
+
desidata download gender-policy-of-nabard-question-and-answer-dataset -o nabard.csv
|
|
114
|
+
desidata clear-cache
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Error handling
|
|
118
|
+
|
|
119
|
+
Every error the package raises subclasses `desidata.DesiDataError`:
|
|
120
|
+
|
|
121
|
+
| Exception | Meaning |
|
|
122
|
+
| --- | --- |
|
|
123
|
+
| `DesiDataNotFound` | That dataset doesn't exist (HTTP 404) |
|
|
124
|
+
| `DesiDataConnectionError` | Network/DNS/timeout failure |
|
|
125
|
+
| `DesiDataServerError` | desidata.in returned an unexpected error |
|
|
126
|
+
|
|
127
|
+
```python
|
|
128
|
+
try:
|
|
129
|
+
df = desidata.load("some-slug")
|
|
130
|
+
except desidata.DesiDataNotFound:
|
|
131
|
+
print("Check the slug with desidata.search(...)")
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
git clone https://github.com/krishnakaushik195/desidata-py
|
|
138
|
+
cd desidata-py
|
|
139
|
+
pip install -e .[dev]
|
|
140
|
+
pytest # offline unit tests
|
|
141
|
+
RUN_LIVE=1 pytest tests/test_live.py -v # integration tests against the real site
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Environment overrides used by tests and local development:
|
|
145
|
+
|
|
146
|
+
| Variable | Purpose |
|
|
147
|
+
| --- | --- |
|
|
148
|
+
| `DESIDATA_BASE_URL` | Point the client at a local/dev server |
|
|
149
|
+
| `DESIDATA_CACHE_DIR` | Relocate the cache |
|
|
150
|
+
| `DESIDATA_TIMEOUT` | Default request timeout in seconds |
|
|
151
|
+
|
|
152
|
+
## Releasing
|
|
153
|
+
|
|
154
|
+
Maintainers only:
|
|
155
|
+
|
|
156
|
+
```bash
|
|
157
|
+
pip install --upgrade build twine
|
|
158
|
+
python -m build
|
|
159
|
+
twine upload dist/*
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
## Licence
|
|
163
|
+
|
|
164
|
+
MIT — see [LICENSE](LICENSE). The data itself belongs to its sources; each
|
|
165
|
+
dataset page on [desidata.in](https://www.desidata.in) lists its source and licence.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/desidata/__init__.py
|
|
5
|
+
src/desidata/__main__.py
|
|
6
|
+
src/desidata/cli.py
|
|
7
|
+
src/desidata/client.py
|
|
8
|
+
src/desidata.egg-info/PKG-INFO
|
|
9
|
+
src/desidata.egg-info/SOURCES.txt
|
|
10
|
+
src/desidata.egg-info/dependency_links.txt
|
|
11
|
+
src/desidata.egg-info/entry_points.txt
|
|
12
|
+
src/desidata.egg-info/requires.txt
|
|
13
|
+
src/desidata.egg-info/top_level.txt
|
|
14
|
+
tests/test_cli.py
|
|
15
|
+
tests/test_client.py
|
|
16
|
+
tests/test_live.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
desidata
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""CLI tests with API calls stubbed at the client boundary."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from desidata import cli, client
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture()
|
|
12
|
+
def runner(capsys):
|
|
13
|
+
def run(*argv):
|
|
14
|
+
code = cli.main(list(argv))
|
|
15
|
+
return code, capsys.readouterr().out
|
|
16
|
+
|
|
17
|
+
return run
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_cli_version_flag(runner):
|
|
21
|
+
with pytest.raises(SystemExit) as excinfo:
|
|
22
|
+
cli.main(["--version"])
|
|
23
|
+
assert excinfo.value.code == 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_cli_info_prints_fields(runner, monkeypatch):
|
|
27
|
+
monkeypatch.setattr(
|
|
28
|
+
cli, "fetch_info", lambda slug: {"slug": slug, "name": "Prices", "downloads": 7}
|
|
29
|
+
)
|
|
30
|
+
code, out = runner("info", "prices")
|
|
31
|
+
assert code == 0
|
|
32
|
+
assert "prices" in out and "Prices" in out and "7" in out
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_cli_search_prints_table(runner, monkeypatch):
|
|
36
|
+
monkeypatch.setattr(
|
|
37
|
+
cli,
|
|
38
|
+
"search_datasets",
|
|
39
|
+
lambda query: [{"slug": "crop-prices", "category": "Agriculture", "rows": 100, "title": "Crop"}],
|
|
40
|
+
)
|
|
41
|
+
code, out = runner("search", "crop")
|
|
42
|
+
assert code == 0
|
|
43
|
+
assert "crop-prices" in out and "Agriculture" in out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_cli_catalog_category_passthrough(runner, monkeypatch):
|
|
47
|
+
seen = {}
|
|
48
|
+
|
|
49
|
+
def fake_catalog(category=None):
|
|
50
|
+
seen["category"] = category
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
monkeypatch.setattr(cli, "fetch_catalog", fake_catalog)
|
|
54
|
+
code, out = runner("catalog", "--category", "Economy")
|
|
55
|
+
assert code == 0
|
|
56
|
+
assert seen["category"] == "Economy"
|
|
57
|
+
assert "No datasets found." in out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_cli_download_writes_file(runner, monkeypatch, tmp_path):
|
|
61
|
+
monkeypatch.setattr(cli, "fetch_csv_bytes", lambda slug, refresh=False: b"a,b\n1,2\n")
|
|
62
|
+
output = tmp_path / "out.csv"
|
|
63
|
+
code, out = runner("download", "some-slug", "-o", str(output))
|
|
64
|
+
assert code == 0
|
|
65
|
+
assert output.read_bytes() == b"a,b\n1,2\n"
|
|
66
|
+
assert "8 bytes" in out
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_cli_error_returns_1(runner, monkeypatch):
|
|
70
|
+
def boom(slug):
|
|
71
|
+
raise client.DesiDataNotFound("Not found: x")
|
|
72
|
+
|
|
73
|
+
monkeypatch.setattr(cli, "fetch_info", boom)
|
|
74
|
+
code, _ = runner("info", "x")
|
|
75
|
+
assert code == 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_cli_clear_cache(runner, monkeypatch):
|
|
79
|
+
monkeypatch.setattr(cli, "clear_cache", lambda: 3)
|
|
80
|
+
code, out = runner("clear-cache")
|
|
81
|
+
assert code == 0
|
|
82
|
+
assert "3" in out
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Unit tests with the network fully mocked out."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import urllib.error
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from desidata import client
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class FakeResponse:
|
|
13
|
+
def __init__(self, payload: bytes):
|
|
14
|
+
self._payload = payload
|
|
15
|
+
|
|
16
|
+
def read(self) -> bytes:
|
|
17
|
+
return self._payload
|
|
18
|
+
|
|
19
|
+
def __enter__(self):
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
def __exit__(self, *args):
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture()
|
|
27
|
+
def cache_dir(tmp_path, monkeypatch):
|
|
28
|
+
directory = tmp_path / "cache"
|
|
29
|
+
directory.mkdir()
|
|
30
|
+
monkeypatch.setenv("DESIDATA_CACHE_DIR", str(directory))
|
|
31
|
+
return directory
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_version_is_exported():
|
|
35
|
+
assert client.__version__.count(".") == 2
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_normalise_slug_accepts_bare_slug():
|
|
39
|
+
assert client._normalise_slug("crop-prices-2021") == "crop-prices-2021"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_normalise_slug_accepts_full_url():
|
|
43
|
+
url = "https://www.desidata.in/datasets/gender-policy-of-nabard-question-and-answer-dataset"
|
|
44
|
+
assert client._normalise_slug(url) == "gender-policy-of-nabard-question-and-answer-dataset"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_normalise_slug_rejects_traversal():
|
|
48
|
+
with pytest.raises(ValueError):
|
|
49
|
+
client._normalise_slug("../../etc/passwd")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_fetch_info_parses_json(monkeypatch):
|
|
53
|
+
payload = {"slug": "x", "name": "X", "downloads": 5}
|
|
54
|
+
calls = []
|
|
55
|
+
|
|
56
|
+
def fake_urlopen(request, timeout=None):
|
|
57
|
+
calls.append(request.full_url)
|
|
58
|
+
return FakeResponse(json.dumps(payload).encode("utf-8"))
|
|
59
|
+
|
|
60
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
61
|
+
result = client.fetch_info("x")
|
|
62
|
+
|
|
63
|
+
assert result == payload
|
|
64
|
+
assert calls == ["https://www.desidata.in/api/datasets/x"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_fetch_info_404_raises_not_found(monkeypatch):
|
|
68
|
+
def fake_urlopen(request, timeout=None):
|
|
69
|
+
raise urllib.error.HTTPError(request.full_url, 404, "Not Found", None, None)
|
|
70
|
+
|
|
71
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
72
|
+
with pytest.raises(client.DesiDataNotFound):
|
|
73
|
+
client.fetch_info("missing-slug")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_urlerror_maps_to_connection_error(monkeypatch):
|
|
77
|
+
def fake_urlopen(request, timeout=None):
|
|
78
|
+
raise urllib.error.URLError("no route to host")
|
|
79
|
+
|
|
80
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
81
|
+
with pytest.raises(client.DesiDataConnectionError):
|
|
82
|
+
client.fetch_info("any-slug")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_fetch_catalog_builds_filter_url(monkeypatch):
|
|
86
|
+
calls = []
|
|
87
|
+
|
|
88
|
+
def fake_urlopen(request, timeout=None):
|
|
89
|
+
calls.append(request.full_url)
|
|
90
|
+
return FakeResponse(json.dumps({"count": 1, "datasets": [{"slug": "a"}]}).encode("utf-8"))
|
|
91
|
+
|
|
92
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
93
|
+
result = client.fetch_catalog(category="Economy", language="English")
|
|
94
|
+
|
|
95
|
+
assert result == [{"slug": "a"}]
|
|
96
|
+
assert "category=Economy" in calls[0]
|
|
97
|
+
assert "language=English" in calls[0]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_fetch_csv_bytes_caches(monkeypatch, cache_dir):
|
|
101
|
+
calls = []
|
|
102
|
+
|
|
103
|
+
def fake_urlopen(request, timeout=None):
|
|
104
|
+
calls.append(request.full_url)
|
|
105
|
+
return FakeResponse(b"col1,col2\n1,2\n")
|
|
106
|
+
|
|
107
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
108
|
+
|
|
109
|
+
first = client.fetch_csv_bytes("some-dataset")
|
|
110
|
+
second = client.fetch_csv_bytes("some-dataset")
|
|
111
|
+
|
|
112
|
+
assert first == second == b"col1,col2\n1,2\n"
|
|
113
|
+
assert len(calls) == 1 # second call came from cache
|
|
114
|
+
assert (cache_dir / "some-dataset.csv").exists()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_fetch_csv_bytes_refresh_bypasses_cache(monkeypatch, cache_dir):
|
|
118
|
+
calls = []
|
|
119
|
+
|
|
120
|
+
def fake_urlopen(request, timeout=None):
|
|
121
|
+
calls.append(request.full_url)
|
|
122
|
+
return FakeResponse(b"a\n1\n")
|
|
123
|
+
|
|
124
|
+
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
|
125
|
+
|
|
126
|
+
client.fetch_csv_bytes("some-dataset")
|
|
127
|
+
client.fetch_csv_bytes("some-dataset", refresh=True)
|
|
128
|
+
|
|
129
|
+
assert len(calls) == 2
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_clear_cache_counts_removals(monkeypatch, cache_dir):
|
|
133
|
+
for slug in ("one", "two"):
|
|
134
|
+
(cache_dir / f"{slug}.csv").write_bytes(b"a\n")
|
|
135
|
+
assert client.clear_cache() == 2
|
|
136
|
+
assert not list(cache_dir.glob("*.csv"))
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Live integration tests against the real desidata.in.
|
|
2
|
+
|
|
3
|
+
Skipped unless RUN_LIVE=1, so `pytest` stays offline-friendly:
|
|
4
|
+
RUN_LIVE=1 pytest tests/test_live.py -v
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import desidata
|
|
13
|
+
from desidata import client
|
|
14
|
+
|
|
15
|
+
pytestmark = pytest.mark.skipif(
|
|
16
|
+
os.environ.get("RUN_LIVE") != "1",
|
|
17
|
+
reason="live tests are opt-in (RUN_LIVE=1)",
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
NABARD = "gender-policy-of-nabard-question-and-answer-dataset"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_live_info():
|
|
24
|
+
metadata = desidata.info(NABARD)
|
|
25
|
+
assert metadata["slug"] == NABARD
|
|
26
|
+
assert metadata["format"].lower() == "csv"
|
|
27
|
+
assert "download_url" in metadata
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_live_catalog_is_large():
|
|
31
|
+
datasets = desidata.catalog()
|
|
32
|
+
assert len(datasets) > 100
|
|
33
|
+
sample = datasets[0]
|
|
34
|
+
for key in ("slug", "title", "category", "url"):
|
|
35
|
+
assert key in sample
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_live_search():
|
|
39
|
+
results = desidata.search("nabard")
|
|
40
|
+
assert any("nabard" in row["slug"] for row in results)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_live_load_dataframe():
|
|
44
|
+
df = desidata.load(NABARD)
|
|
45
|
+
assert df.shape[0] > 0
|
|
46
|
+
assert "question" in df.columns
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_live_download_bytes():
|
|
50
|
+
data = desidata.download(NABARD)
|
|
51
|
+
assert data[:4] in (b"ques", b"\"que") # header starts with the question column
|
|
52
|
+
assert b"," in data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_live_not_found():
|
|
56
|
+
with pytest.raises(client.DesiDataNotFound):
|
|
57
|
+
desidata.info("this-dataset-does-not-exist-12345")
|