isaac-data 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
isaac_data/__init__.py ADDED
@@ -0,0 +1,54 @@
1
+ """isaac-data: a thin Python loader for the ISAAC Reddit corpus.
2
+
3
+ Reads the ISAAC Direct Download endpoint
4
+ (https://isaac.psychology.illinois.edu/direct-download/) using the published
5
+ manifest as a catalog. Parquet reads support column pushdown over HTTP.
6
+
7
+ Quick start
8
+ -----------
9
+ >>> import isaac_data as isaac
10
+ >>> isaac.files("race", "2018-01", "2018-12") # what's available
11
+ >>> df = isaac.load("race", "2018-03", "2018-03", # one month, two columns
12
+ ... columns=["text", "score"])
13
+ >>> isaac.download("age", "2015-01", "2015-12", dest="./age2015") # bulk fetch
14
+ """
15
+ from .core import (
16
+ BASE_URL,
17
+ CATEGORIES,
18
+ DATA_BASE,
19
+ MANIFEST_URL,
20
+ DataHostUnavailable,
21
+ cache_dir,
22
+ catalog,
23
+ download,
24
+ files,
25
+ load,
26
+ read_parquet,
27
+ set_cache_dir,
28
+ )
29
+ from .agreement import (
30
+ AgreementNotAccepted,
31
+ accept_agreement,
32
+ is_accepted,
33
+ status as agreement_status,
34
+ withdraw as withdraw_agreement,
35
+ )
36
+
37
+ # Pre-2026-07-25 names ("Terms of Use" era), kept importable for compatibility.
38
+ TermsNotAccepted = AgreementNotAccepted
39
+ accept_terms = accept_agreement
40
+ terms_status = agreement_status
41
+ withdraw_terms = withdraw_agreement
42
+
43
+ __version__ = "0.1.3"
44
+
45
+ __all__ = [
46
+ "__version__",
47
+ "BASE_URL", "DATA_BASE", "MANIFEST_URL", "CATEGORIES",
48
+ "cache_dir", "set_cache_dir", "catalog", "files",
49
+ "download", "read_parquet", "load", "DataHostUnavailable",
50
+ "accept_agreement", "is_accepted", "agreement_status", "withdraw_agreement",
51
+ "AgreementNotAccepted",
52
+ # deprecated aliases
53
+ "accept_terms", "terms_status", "withdraw_terms", "TermsNotAccepted",
54
+ ]
@@ -0,0 +1,397 @@
1
+ """First-run Data Use Agreement acceptance.
2
+
3
+ Data access (`load`, `download`, remote `read_parquet`) requires acceptance of
4
+ the ISAAC Data Use Agreement. Browsing the catalog (`catalog`, `files`) does not.
5
+
6
+ Accepting requires an email address. It is recorded locally (OS-native config
7
+ dir) and posted to the ISAAC server, so the project has one record across access
8
+ surfaces and a way to reach users about agreement changes and corpus errata. The
9
+ POST itself is best-effort: a network failure never blocks data access, and the
10
+ local record notes whether the server acknowledged it.
11
+
12
+ The agreement text is fetched from the ISAAC server's /dua endpoint, which
13
+ serves the live document out of the corpus repo along with a SHA-256 of the
14
+ exact bytes. That hash is what identifies the version, so:
15
+
16
+ * the hash recorded here matches the one the website records, and
17
+ * if the agreement changes, `require_acceptance` notices and re-prompts
18
+ rather than honoring a stale acceptance forever.
19
+
20
+ Non-interactive use (CI, notebooks without a TTY) must either accept beforehand
21
+ via `isaac-data accept-agreement` or set ``ISAAC_ACCEPT_AGREEMENT=1``. Either way
22
+ ``ISAAC_AGREEMENT_EMAIL`` (or ``--email``) must supply the address, since there is
23
+ no prompt to fall back on. Otherwise data access raises ``AgreementNotAccepted``.
24
+
25
+ Renamed 2026-07-25: the document was previously called the "Terms of Use". The
26
+ old names (module ``isaac_data.terms``, ``TermsNotAccepted``, ``accept_terms``,
27
+ ``ISAAC_ACCEPT_TERMS``, ``isaac-data accept-terms``) still work as aliases.
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import re
35
+ import sys
36
+ import time
37
+ import uuid
38
+ from pathlib import Path
39
+ from typing import Optional
40
+
41
+ AGREEMENT_PAGE = "https://github.com/BabakHemmatian/Illinois_Social_Attitudes/blob/main/Data_Use_Agreement.md"
42
+ AGREEMENT_RAW = "https://raw.githubusercontent.com/BabakHemmatian/Illinois_Social_Attitudes/main/Data_Use_Agreement.md"
43
+ _ENV = "ISAAC_ACCEPT_AGREEMENT"
44
+ _ENV_LEGACY = "ISAAC_ACCEPT_TERMS" # pre-2026-07-25 name, still honored
45
+ _ENV_EMAIL = "ISAAC_AGREEMENT_EMAIL"
46
+
47
+ # Why we ask for an email. Keep this in sync with the wording on the website and
48
+ # the HuggingFace dataset card — and keep it accurate: do not promise uses we do
49
+ # not actually carry out.
50
+ EMAIL_PURPOSE = (
51
+ "We ask for your email so we can notify you of changes to the Data Use\n"
52
+ "Agreement and of corrections or errata affecting the corpus, and to keep a\n"
53
+ "record of your acceptance. We do not share it, and we don't use it for\n"
54
+ "anything else."
55
+ )
56
+
57
+ # How often to re-verify that the accepted agreement is still the current one.
58
+ # Matches the manifest cache policy in core.py; a miss costs one small GET.
59
+ _VERSION_CHECK_TTL_SECONDS = 24 * 3600
60
+
61
+ # Deliberately permissive: this is a typo guard, not identity verification.
62
+ _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
63
+
64
+
65
+ class AgreementNotAccepted(RuntimeError):
66
+ """Raised when the ISAAC Data Use Agreement has not been accepted."""
67
+
68
+
69
+ def _env_opt_in() -> bool:
70
+ for name in (_ENV, _ENV_LEGACY):
71
+ if os.environ.get(name, "").strip().lower() in ("1", "true", "yes"):
72
+ return True
73
+ return False
74
+
75
+
76
+ def _env_email() -> Optional[str]:
77
+ val = os.environ.get(_ENV_EMAIL, "").strip()
78
+ return val if val and _EMAIL_RE.match(val) else None
79
+
80
+
81
+ def _base_url() -> str:
82
+ # Read at call time (not import time) so ISAAC_BASE_URL can be set in-process.
83
+ return os.environ.get("ISAAC_BASE_URL", "https://isaac.psychology.illinois.edu").rstrip("/")
84
+
85
+
86
+ def _config_dir() -> Path:
87
+ override = os.environ.get("ISAAC_DATA_CONFIG")
88
+ if override:
89
+ d = Path(override).expanduser()
90
+ else:
91
+ import platformdirs
92
+ d = Path(platformdirs.user_config_dir("isaac-data"))
93
+ d.mkdir(parents=True, exist_ok=True)
94
+ return d
95
+
96
+
97
+ def _record_file() -> Path:
98
+ return _config_dir() / "accepted.json"
99
+
100
+
101
+ def _read_record() -> Optional[dict]:
102
+ f = _record_file()
103
+ if not f.exists():
104
+ return None
105
+ try:
106
+ return json.loads(f.read_text())
107
+ except Exception:
108
+ # Unreadable record: treat as "accepted, version unknown" rather than
109
+ # forcing a re-prompt because of a corrupt file.
110
+ return {"accepted": True, "record": str(f)}
111
+
112
+
113
+ def is_accepted() -> bool:
114
+ """True if this machine has an acceptance record. Cheap; never hits network.
115
+
116
+ Does not check whether the accepted version is still current —
117
+ `require_acceptance` does that, so this stays usable as a fast predicate.
118
+ """
119
+ return _record_file().exists()
120
+
121
+
122
+ def status() -> Optional[dict]:
123
+ """Return the local acceptance record, or None if not yet accepted."""
124
+ return _read_record()
125
+
126
+
127
+ def fetch_agreement_record(timeout: int = 15) -> Optional[dict]:
128
+ """Fetch the current agreement text plus its version identifiers.
129
+
130
+ Prefers the ISAAC server's /dua endpoint so the SHA-256 recorded here is the
131
+ same one the website records. Falls back to raw GitHub (hashing the bytes
132
+ ourselves) if the server is unreachable. Returns None if both fail.
133
+ """
134
+ import requests
135
+ try:
136
+ r = requests.get(f"{_base_url()}/dua", timeout=timeout,
137
+ headers={"Accept": "application/json"})
138
+ if r.ok:
139
+ d = r.json()
140
+ if d.get("markdown"):
141
+ return {
142
+ "text": d["markdown"],
143
+ "sha256": d.get("sha256"),
144
+ "commit": d.get("commit"),
145
+ "version": d.get("version"),
146
+ }
147
+ except Exception:
148
+ pass
149
+ try:
150
+ r = requests.get(AGREEMENT_RAW, timeout=timeout)
151
+ if r.ok and r.text.strip():
152
+ return {
153
+ "text": r.text,
154
+ "sha256": hashlib.sha256(r.content).hexdigest(),
155
+ "commit": None,
156
+ "version": None,
157
+ }
158
+ except Exception:
159
+ pass
160
+ return None
161
+
162
+
163
+ def fetch_agreement(timeout: int = 15) -> Optional[str]:
164
+ """Best-effort fetch of the current Data Use Agreement text (None if unavailable)."""
165
+ rec = fetch_agreement_record(timeout=timeout)
166
+ return rec["text"] if rec else None
167
+
168
+
169
+ def withdraw() -> bool:
170
+ """Delete the local acceptance record. Returns True if one existed."""
171
+ f = _record_file()
172
+ if f.exists():
173
+ f.unlink()
174
+ return True
175
+ return False
176
+
177
+
178
+ def _client_id() -> str:
179
+ """Stable per-machine id, so repeat acceptances are recognisable as one user.
180
+
181
+ Not an identity claim — just a correlation handle for the consent log.
182
+ """
183
+ prev = _read_record() or {}
184
+ return prev.get("client_id") or f"pypi:{uuid.uuid4()}"
185
+
186
+
187
+ def _post_consent(rec: dict, timeout: int = 10) -> bool:
188
+ """Best-effort POST of the acceptance to the ISAAC server. Never raises."""
189
+ if not rec.get("email"):
190
+ return False
191
+ import requests
192
+ from . import __version__
193
+ try:
194
+ r = requests.post(
195
+ f"{_base_url()}/record_consent",
196
+ json={
197
+ "uid": rec["client_id"],
198
+ "email": rec["email"],
199
+ "agreement_version": rec.get("agreement_version") or "unknown",
200
+ "agreement_sha256": rec.get("agreement_sha256"),
201
+ "agreement_commit": rec.get("agreement_commit"),
202
+ "accepted_at": rec["accepted_at_utc"],
203
+ "source": "pypi",
204
+ "package_version": __version__,
205
+ },
206
+ timeout=timeout,
207
+ )
208
+ return bool(r.ok)
209
+ except Exception:
210
+ return False
211
+
212
+
213
+ def _prompt_email(out) -> str:
214
+ """Ask for an email address. Required; raises if one is not supplied.
215
+
216
+ An address is part of accepting the agreement (it is how we reach you about
217
+ changes and errata), so there is no skip option here. Ctrl-C still aborts.
218
+ """
219
+ print(EMAIL_PURPOSE, file=out)
220
+ print(file=out)
221
+ for _ in range(5):
222
+ try:
223
+ resp = input("Your email address: ").strip()
224
+ except EOFError:
225
+ break
226
+ if _EMAIL_RE.match(resp):
227
+ return resp
228
+ if resp:
229
+ print("That doesn't look like an email address; please try again.", file=out)
230
+ else:
231
+ print("An email address is required to accept the agreement.", file=out)
232
+ raise AgreementNotAccepted(
233
+ "An email address is required to accept the ISAAC Data Use Agreement; aborting."
234
+ )
235
+
236
+
237
+ def _require_email(email: Optional[str]) -> str:
238
+ """Validate a non-interactively supplied address, with an actionable error."""
239
+ if email and _EMAIL_RE.match(email):
240
+ return email
241
+ raise AgreementNotAccepted(
242
+ "An email address is required to accept the ISAAC Data Use Agreement.\n"
243
+ f"Pass `isaac-data accept-agreement --email you@example.edu` or set {_ENV_EMAIL}."
244
+ + ("" if not email else f"\n(Got an address that doesn't parse: {email!r})")
245
+ )
246
+
247
+
248
+ def _write_record(agreement: Optional[dict], email: Optional[str], via: str) -> dict:
249
+ from . import __version__
250
+ now = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
251
+ rec = {
252
+ "accepted": True,
253
+ "accepted_at_utc": now,
254
+ "agreement_url": AGREEMENT_PAGE,
255
+ "agreement_sha256": (agreement or {}).get("sha256"),
256
+ "agreement_commit": (agreement or {}).get("commit"),
257
+ "agreement_version": (agreement or {}).get("version"),
258
+ "email": email,
259
+ "client_id": _client_id(),
260
+ "accepted_via": via, # "prompt" | "env" | "assume_yes"
261
+ "package_version": __version__,
262
+ "last_version_check_utc": now,
263
+ }
264
+ rec["server_ack"] = _post_consent(rec)
265
+ _record_file().write_text(json.dumps(rec, indent=2) + "\n")
266
+ return rec
267
+
268
+
269
+ def _print_agreement(agreement: Optional[dict], out) -> None:
270
+ print("\n" + "=" * 72, file=out)
271
+ print("ISAAC dataset: Data Use Agreement", file=out)
272
+ print("=" * 72, file=out)
273
+ if agreement and agreement.get("text"):
274
+ print(agreement["text"].strip(), file=out)
275
+ if agreement.get("version"):
276
+ print(f"\n[version {agreement['version']}]", file=out)
277
+ else:
278
+ print("By using the ISAAC dataset and this package you agree to the ISAAC", file=out)
279
+ print(f"Data Use Agreement:\n {AGREEMENT_PAGE}", file=out)
280
+ print("-" * 72, file=out)
281
+
282
+
283
+ def accept_agreement(assume_yes: bool = False, email: Optional[str] = None) -> dict:
284
+ """Show the Data Use Agreement and record acceptance.
285
+
286
+ assume_yes : skip the prompt (equivalent to ``ISAAC_ACCEPT_AGREEMENT=1``).
287
+ email : contact address to record; falls back to ``ISAAC_AGREEMENT_EMAIL``,
288
+ then to an interactive prompt. Required either way.
289
+
290
+ Raises AgreementNotAccepted if the user declines, if no email is supplied, or
291
+ if no terminal is available to ask on.
292
+ """
293
+ out = sys.stderr
294
+ agreement = fetch_agreement_record()
295
+ _print_agreement(agreement, out)
296
+
297
+ if assume_yes or _env_opt_in():
298
+ rec = _write_record(agreement, _require_email(email or _env_email()),
299
+ via="assume_yes" if assume_yes else "env")
300
+ print(f"Agreement accepted (recorded at {_record_file()}).", file=out)
301
+ return rec
302
+
303
+ if not (sys.stdin and sys.stdin.isatty()):
304
+ raise AgreementNotAccepted(
305
+ "ISAAC Data Use Agreement not accepted and no interactive terminal is available.\n"
306
+ f"Read {AGREEMENT_PAGE}, then run `isaac-data accept-agreement` or set {_ENV}=1."
307
+ )
308
+
309
+ resp = input("Do you accept the ISAAC Data Use Agreement? [y/N] ").strip().lower()
310
+ if resp not in ("y", "yes"):
311
+ raise AgreementNotAccepted("ISAAC Data Use Agreement was not accepted; aborting.")
312
+
313
+ if email is None:
314
+ email = _env_email() or _prompt_email(out)
315
+
316
+ rec = _write_record(agreement, email, via="prompt")
317
+ print(f"Thank you. Acceptance recorded at {_record_file()}.", file=out)
318
+ return rec
319
+
320
+
321
+ def _needs_version_recheck(rec: dict) -> bool:
322
+ """True if enough time has passed to re-verify the accepted version."""
323
+ last = rec.get("last_version_check_utc")
324
+ if not last:
325
+ return True
326
+ try:
327
+ import calendar
328
+ elapsed = time.time() - calendar.timegm(time.strptime(last, "%Y-%m-%dT%H:%M:%SZ"))
329
+ except Exception:
330
+ return True
331
+ return elapsed > _VERSION_CHECK_TTL_SECONDS
332
+
333
+
334
+ def _touch_version_check(rec: dict) -> None:
335
+ rec["last_version_check_utc"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
336
+ try:
337
+ _record_file().write_text(json.dumps(rec, indent=2) + "\n")
338
+ except Exception:
339
+ pass
340
+
341
+
342
+ def require_acceptance() -> None:
343
+ """Gate called before data access. Cheap once accepted and up to date.
344
+
345
+ Re-prompts if the agreement text has changed since it was accepted, checking
346
+ at most once per `_VERSION_CHECK_TTL_SECONDS`. If the check cannot reach the
347
+ network it proceeds on the existing acceptance — being offline must not
348
+ block a user who has already agreed.
349
+ """
350
+ rec = _read_record()
351
+ if rec is None:
352
+ if _env_opt_in():
353
+ _write_record(fetch_agreement_record(), _require_email(_env_email()), via="env")
354
+ return
355
+ accept_agreement()
356
+ return
357
+
358
+ accepted_sha = rec.get("agreement_sha256")
359
+ if not accepted_sha or not _needs_version_recheck(rec):
360
+ return
361
+
362
+ current = fetch_agreement_record()
363
+ if current is None or not current.get("sha256"):
364
+ return # offline or server down: honor the existing acceptance
365
+ if current["sha256"] == accepted_sha:
366
+ _touch_version_check(rec)
367
+ return
368
+
369
+ # The agreement changed; acceptance of the old text does not carry over.
370
+ print("\nThe ISAAC Data Use Agreement has changed since you accepted it.",
371
+ file=sys.stderr)
372
+ if _env_opt_in():
373
+ # Carry the address forward from the prior acceptance where we have one.
374
+ _write_record(current, _require_email(rec.get("email") or _env_email()), via="env")
375
+ return
376
+ if not (sys.stdin and sys.stdin.isatty()):
377
+ raise AgreementNotAccepted(
378
+ "The ISAAC Data Use Agreement has changed and the new version has not "
379
+ "been accepted, and no interactive terminal is available.\n"
380
+ f"Review it at {AGREEMENT_PAGE}, then run `isaac-data accept-agreement` "
381
+ f"or set {_ENV}=1."
382
+ )
383
+ _print_agreement(current, sys.stderr)
384
+ resp = input("Do you accept the updated ISAAC Data Use Agreement? [y/N] ").strip().lower()
385
+ if resp not in ("y", "yes"):
386
+ raise AgreementNotAccepted(
387
+ "The updated ISAAC Data Use Agreement was not accepted; aborting."
388
+ )
389
+ _write_record(current, rec.get("email") or _env_email(), via="prompt")
390
+
391
+
392
+ # ---- Pre-2026-07-25 aliases ("Terms of Use" era). Kept for compatibility. ----
393
+ TermsNotAccepted = AgreementNotAccepted
394
+ TERMS_PAGE = AGREEMENT_PAGE
395
+ TERMS_RAW = AGREEMENT_RAW
396
+ fetch_terms = fetch_agreement
397
+ accept_terms = accept_agreement
isaac_data/cli.py ADDED
@@ -0,0 +1,112 @@
1
+ """Command-line interface for isaac-data.
2
+
3
+ Examples
4
+ --------
5
+ isaac-data ls --category race --start 2018-01 --end 2018-12
6
+ isaac-data info
7
+ isaac-data download --category age --start 2015-01 --end 2015-12 --dest ./age2015
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import sys
14
+
15
+ from . import __version__
16
+ from .core import CATEGORIES, download, files
17
+
18
+
19
+ def _add_selection_args(p: argparse.ArgumentParser) -> None:
20
+ p.add_argument("-c", "--category", choices=CATEGORIES, help="social group (default: all)")
21
+ p.add_argument("-s", "--start", help="start month, YYYY-MM (inclusive)")
22
+ p.add_argument("-e", "--end", help="end month, YYYY-MM (inclusive)")
23
+ p.add_argument("-f", "--format", dest="fmt", default="parquet",
24
+ choices=["parquet", "csv", "both"], help="file format (default: parquet)")
25
+
26
+
27
+ def _fmt(args) -> str | None:
28
+ return None if args.fmt == "both" else args.fmt
29
+
30
+
31
+ def main(argv=None) -> int:
32
+ parser = argparse.ArgumentParser(prog="isaac-data", description="ISAAC corpus direct-download helper.")
33
+ parser.add_argument("--version", action="version", version=f"isaac-data {__version__}")
34
+ # metavar lists only the public commands; `accept-terms` is a hidden
35
+ # pre-2026-07-25 alias that still parses.
36
+ sub = parser.add_subparsers(
37
+ dest="cmd", required=True,
38
+ metavar="{ls,info,download,accept-agreement}",
39
+ )
40
+
41
+ p_ls = sub.add_parser("ls", help="list matching files")
42
+ _add_selection_args(p_ls)
43
+
44
+ p_info = sub.add_parser("info", help="summary of the whole corpus")
45
+
46
+ p_dl = sub.add_parser("download", help="download matching files (resumable)")
47
+ _add_selection_args(p_dl)
48
+ p_dl.add_argument("-d", "--dest", help="destination directory (default: cache)")
49
+
50
+ for _name, _kw in (
51
+ ("accept-agreement", {"help": "review & accept the Data Use Agreement (recorded locally)"}),
52
+ # Pre-2026-07-25 name, kept working; no `help` so it stays out of --help.
53
+ ("accept-terms", {}),
54
+ ):
55
+ p_acc = sub.add_parser(_name, **_kw)
56
+ p_acc.add_argument("-y", "--yes", action="store_true", help="accept without the interactive prompt")
57
+ p_acc.add_argument("--email", help="contact address to record (else ISAAC_AGREEMENT_EMAIL, else prompted)")
58
+ p_acc.add_argument("--status", action="store_true", help="show current acceptance record and exit")
59
+ p_acc.add_argument("--withdraw", action="store_true", help="delete the local acceptance record")
60
+
61
+ args = parser.parse_args(argv)
62
+
63
+ if args.cmd in ("accept-agreement", "accept-terms"):
64
+ from .agreement import accept_agreement, status, withdraw, AgreementNotAccepted
65
+ if args.status:
66
+ s = status()
67
+ print(json.dumps(s, indent=2) if s else "Data Use Agreement not yet accepted on this machine.")
68
+ return 0
69
+ if args.withdraw:
70
+ print("Removed local acceptance record." if withdraw() else "No acceptance record to remove.")
71
+ return 0
72
+ try:
73
+ accept_agreement(assume_yes=args.yes, email=args.email)
74
+ return 0
75
+ except AgreementNotAccepted as e:
76
+ print(str(e), file=sys.stderr)
77
+ return 2
78
+
79
+ if args.cmd == "ls":
80
+ df = files(args.category, args.start, args.end, _fmt(args))
81
+ cols = ["category", "year", "month", "format", "size_bytes", "num_rows", "url"]
82
+ with_pd_print(df[cols])
83
+ print(f"\n{len(df)} files, {df['size_bytes'].sum()/1e9:.2f} GB", file=sys.stderr)
84
+ return 0
85
+
86
+ if args.cmd == "info":
87
+ df = files(None, None, None, None)
88
+ import pandas as pd # noqa
89
+ g = df.groupby(["category", "format"]).agg(files=("url", "size"),
90
+ gb=("size_bytes", lambda s: round(s.sum() / 1e9, 1)))
91
+ with_pd_print(g.reset_index())
92
+ print(f"\nTotal: {len(df)} files, {df['size_bytes'].sum()/1e9:.1f} GB", file=sys.stderr)
93
+ return 0
94
+
95
+ if args.cmd == "download":
96
+ paths = download(args.category, args.start, args.end, _fmt(args), dest=args.dest)
97
+ for p in paths:
98
+ print(p)
99
+ print(f"\nDownloaded {len(paths)} file(s).", file=sys.stderr)
100
+ return 0
101
+
102
+ return 1
103
+
104
+
105
+ def with_pd_print(df) -> None:
106
+ import pandas as pd
107
+ with pd.option_context("display.max_rows", 200, "display.width", 200):
108
+ print(df.to_string(index=False))
109
+
110
+
111
+ if __name__ == "__main__":
112
+ raise SystemExit(main())