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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ """python -m desidata support."""
2
+ from .cli import main
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -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,2 @@
1
+ [console_scripts]
2
+ desidata = desidata.cli:main
@@ -0,0 +1,6 @@
1
+
2
+ [dev]
3
+ pytest>=7
4
+
5
+ [pandas]
6
+ pandas>=1.3
@@ -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")