devpost-api 1.0.1__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.
@@ -0,0 +1,56 @@
1
+ """Unofficial Devpost API client with typed models and CLI."""
2
+
3
+ from ._meta import __version__
4
+ from .client import BatchFailure, DevpostClient, ProjectBatchResult, RetryConfig
5
+ from .exceptions import (
6
+ DevpostAPIError,
7
+ GitHubResolutionError,
8
+ HTTPStatusError,
9
+ ParseError,
10
+ RateLimitError,
11
+ )
12
+ from .models import (
13
+ CreatorProfile,
14
+ ExternalLink,
15
+ GitHubRepo,
16
+ HackathonProjectsPage,
17
+ HackathonSummary,
18
+ ProjectDetails,
19
+ SoftwareSummary,
20
+ UserSummary,
21
+ )
22
+ from .parsing import (
23
+ github_owner_repo,
24
+ hackathon_gallery_url,
25
+ parse_hackathon_gallery_page,
26
+ slug_from_project_url,
27
+ to_hackathon_url,
28
+ to_project_url,
29
+ )
30
+
31
+ __all__ = [
32
+ "DevpostClient",
33
+ "BatchFailure",
34
+ "ProjectBatchResult",
35
+ "RetryConfig",
36
+ "DevpostAPIError",
37
+ "HTTPStatusError",
38
+ "ParseError",
39
+ "GitHubResolutionError",
40
+ "RateLimitError",
41
+ "SoftwareSummary",
42
+ "UserSummary",
43
+ "HackathonSummary",
44
+ "HackathonProjectsPage",
45
+ "CreatorProfile",
46
+ "ExternalLink",
47
+ "GitHubRepo",
48
+ "ProjectDetails",
49
+ "to_project_url",
50
+ "to_hackathon_url",
51
+ "slug_from_project_url",
52
+ "github_owner_repo",
53
+ "hackathon_gallery_url",
54
+ "parse_hackathon_gallery_page",
55
+ "__version__",
56
+ ]
devpost_api/_meta.py ADDED
@@ -0,0 +1,9 @@
1
+ """Package metadata used across runtime and docs."""
2
+
3
+ PACKAGE_NAME = "devpost-api"
4
+ __version__ = "1.0.1"
5
+
6
+
7
+ def default_user_agent() -> str:
8
+ """Build the default user-agent from package metadata."""
9
+ return f"{PACKAGE_NAME}/{__version__}"
devpost_api/cli.py ADDED
@@ -0,0 +1,371 @@
1
+ """CLI for the devpost-api package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import csv
7
+ import json
8
+ import sys
9
+ from dataclasses import asdict, is_dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .client import DevpostClient, RetryConfig
14
+ from .exceptions import DevpostAPIError
15
+
16
+
17
+ def main(argv: list[str] | None = None) -> int:
18
+ parser = _build_parser()
19
+ args = parser.parse_args(argv)
20
+
21
+ retry_config = RetryConfig(
22
+ max_attempts=args.retries,
23
+ backoff_factor=args.backoff,
24
+ max_backoff=args.max_backoff,
25
+ jitter=args.jitter,
26
+ )
27
+ client = DevpostClient(
28
+ timeout=args.timeout,
29
+ github_token=args.github_token,
30
+ retry_config=retry_config,
31
+ max_workers=args.workers,
32
+ min_request_interval=args.min_interval,
33
+ cache_ttl=args.cache_ttl,
34
+ )
35
+
36
+ payload: Any
37
+ partial_failures = False
38
+ try:
39
+ if args.command == "software":
40
+ payload = client.list_software(page=args.page, query=args.query)
41
+ elif args.command == "users":
42
+ payload = client.list_users(page=args.page)
43
+ elif args.command == "hackathons":
44
+ payload = client.list_hackathons(page=args.page, status=args.status)
45
+ elif args.command == "project":
46
+ values = _resolve_many_inputs(
47
+ single_value=args.slug_or_url,
48
+ input_file=args.input_file,
49
+ use_stdin=args.stdin,
50
+ parser=parser,
51
+ )
52
+ if len(values) == 1 and args.input_file is None and not args.stdin:
53
+ payload = client.get_project(
54
+ values[0],
55
+ include_github=args.with_github,
56
+ raise_on_error=not args.best_effort,
57
+ )
58
+ else:
59
+ report = client.collect_projects(values, include_github=args.with_github)
60
+ payload = {
61
+ "items": report.projects,
62
+ "errors": report.failures,
63
+ "total_inputs": len(values),
64
+ }
65
+ partial_failures = bool(report.failures)
66
+ if report.failures and not args.best_effort:
67
+ raise DevpostAPIError("Batch project fetch failed; use --best-effort to continue on errors.")
68
+ elif args.command == "hackathon-projects":
69
+ payload = client.list_hackathon_project_urls(args.hackathon, pages=args.pages)
70
+ elif args.command == "scout":
71
+ payload = client.scout_hackathon_projects(
72
+ args.hackathon,
73
+ pages=args.pages,
74
+ include_github=args.with_github,
75
+ raise_on_error=not args.best_effort,
76
+ )
77
+ elif args.command == "github":
78
+ values = _resolve_many_inputs(
79
+ single_value=args.repo,
80
+ input_file=args.input_file,
81
+ use_stdin=args.stdin,
82
+ parser=parser,
83
+ )
84
+ if len(values) == 1 and args.input_file is None and not args.stdin:
85
+ payload = client.get_github_repo(values[0])
86
+ else:
87
+ items: list[Any] = []
88
+ errors: list[dict[str, str]] = []
89
+ for value in values:
90
+ try:
91
+ items.append(client.get_github_repo(value))
92
+ except DevpostAPIError as exc:
93
+ if not args.best_effort:
94
+ raise
95
+ errors.append({"input_value": value, "error": str(exc)})
96
+ payload = {"items": items, "errors": errors, "total_inputs": len(values)}
97
+ partial_failures = bool(errors)
98
+ else:
99
+ parser.print_help()
100
+ return 1
101
+ except DevpostAPIError as exc:
102
+ print(str(exc), file=sys.stderr)
103
+ return 2
104
+
105
+ _render_payload(payload, output_format=args.format, fields=args.fields)
106
+ if partial_failures:
107
+ return 3
108
+ return 0
109
+
110
+
111
+ def _build_parser() -> argparse.ArgumentParser:
112
+ parser = argparse.ArgumentParser(prog="devpost-api", description="Unofficial Devpost API wrapper CLI")
113
+ parser.add_argument("--timeout", type=float, default=15.0, help="HTTP timeout in seconds")
114
+ parser.add_argument("--github-token", default=None, help="Optional GitHub token for higher rate limits")
115
+ parser.add_argument(
116
+ "--format",
117
+ choices=["json", "jsonl", "csv", "table"],
118
+ default="json",
119
+ help="Output format",
120
+ )
121
+ parser.add_argument(
122
+ "--fields",
123
+ default=None,
124
+ help="Comma-separated top-level fields to include in output rows",
125
+ )
126
+ parser.add_argument("--retries", type=int, default=4, help="Maximum retry attempts for transient failures")
127
+ parser.add_argument("--backoff", type=float, default=0.5, help="Backoff factor for retries")
128
+ parser.add_argument("--max-backoff", type=float, default=8.0, help="Maximum retry backoff in seconds")
129
+ parser.add_argument("--jitter", type=float, default=0.25, help="Retry jitter in seconds")
130
+ parser.add_argument("--workers", type=int, default=8, help="Parallel workers for bulk operations")
131
+ parser.add_argument(
132
+ "--min-interval",
133
+ type=float,
134
+ default=0.0,
135
+ help="Global minimum interval between outbound requests in seconds",
136
+ )
137
+ parser.add_argument(
138
+ "--cache-ttl",
139
+ type=float,
140
+ default=0.0,
141
+ help="In-memory cache TTL for GET requests in seconds",
142
+ )
143
+ subparsers = parser.add_subparsers(dest="command", required=True)
144
+
145
+ software = subparsers.add_parser("software", help="List software entries")
146
+ software.add_argument("--page", type=int, default=1)
147
+ software.add_argument("--query", default=None)
148
+
149
+ users = subparsers.add_parser("users", help="List users")
150
+ users.add_argument("--page", type=int, default=1)
151
+
152
+ hackathons = subparsers.add_parser("hackathons", help="List hackathons")
153
+ hackathons.add_argument("--page", type=int, default=1)
154
+ hackathons.add_argument("--status", default=None, help="Example: open")
155
+
156
+ project = subparsers.add_parser("project", help="Fetch project details from a Devpost project page")
157
+ project.add_argument("slug_or_url", nargs="?", help="Project slug, /software/... path, or full URL")
158
+ project.add_argument("--with-github", action="store_true", help="Fetch GitHub metadata for linked repos")
159
+ project.add_argument("--input-file", default=None, help="Path to file with one project URL/slug per line")
160
+ project.add_argument("--stdin", action="store_true", help="Read project URL/slug values from stdin")
161
+ project.add_argument(
162
+ "--best-effort",
163
+ action="store_true",
164
+ help="Skip item failures instead of failing the entire command",
165
+ )
166
+
167
+ hackathon_projects = subparsers.add_parser(
168
+ "hackathon-projects",
169
+ help="List project URLs from a hackathon project gallery",
170
+ )
171
+ hackathon_projects.add_argument(
172
+ "hackathon",
173
+ help="Hackathon slug/domain/url, e.g. treehacks-2025 or https://treehacks-2025.devpost.com",
174
+ )
175
+ hackathon_projects.add_argument(
176
+ "--pages",
177
+ type=_pages_type,
178
+ default=1,
179
+ help="Number of gallery pages to scan; use 'all' to scan all pages",
180
+ )
181
+
182
+ scout = subparsers.add_parser(
183
+ "scout",
184
+ help="Fetch full project details from a hackathon gallery",
185
+ )
186
+ scout.add_argument(
187
+ "hackathon",
188
+ help="Hackathon slug/domain/url, e.g. treehacks-2025 or https://treehacks-2025.devpost.com",
189
+ )
190
+ scout.add_argument(
191
+ "--pages",
192
+ type=_pages_type,
193
+ default=1,
194
+ help="Number of gallery pages to scan; use 'all' to scan all pages",
195
+ )
196
+ scout.add_argument("--with-github", action="store_true", help="Fetch GitHub metadata for linked repos")
197
+ scout.add_argument(
198
+ "--best-effort",
199
+ action="store_true",
200
+ help="Skip per-project failures and return only successful items",
201
+ )
202
+
203
+ github = subparsers.add_parser("github", help="Fetch one or more GitHub repositories by URL or owner/repo")
204
+ github.add_argument("repo", nargs="?")
205
+ github.add_argument("--input-file", default=None, help="Path to file with one repo value per line")
206
+ github.add_argument("--stdin", action="store_true", help="Read repo values from stdin")
207
+ github.add_argument("--best-effort", action="store_true", help="Skip invalid repos in batch mode")
208
+
209
+ return parser
210
+
211
+
212
+ def _jsonable(value: Any) -> Any:
213
+ if is_dataclass(value) and not isinstance(value, type):
214
+ return {k: _jsonable(v) for k, v in asdict(value).items()}
215
+ if isinstance(value, list):
216
+ return [_jsonable(item) for item in value]
217
+ if isinstance(value, dict):
218
+ return {k: _jsonable(v) for k, v in value.items()}
219
+ return value
220
+
221
+
222
+ def _render_payload(payload: Any, *, output_format: str, fields: str | None) -> None:
223
+ jsonable = _jsonable(payload)
224
+ filtered = _apply_fields(jsonable, fields)
225
+ if output_format == "json":
226
+ print(json.dumps(filtered, ensure_ascii=False, indent=2))
227
+ return
228
+ if output_format == "jsonl":
229
+ for item in _to_output_rows(filtered):
230
+ print(json.dumps(item, ensure_ascii=False))
231
+ return
232
+ if output_format == "csv":
233
+ _print_csv(filtered)
234
+ return
235
+ _print_table(filtered)
236
+
237
+
238
+ def _apply_fields(value: Any, fields: str | None) -> Any:
239
+ selected = _parse_fields(fields)
240
+ if not selected:
241
+ return value
242
+ if isinstance(value, list):
243
+ return [_select_fields_from_row(item, selected) for item in value]
244
+ if isinstance(value, dict):
245
+ if "items" in value and isinstance(value["items"], list):
246
+ copied = dict(value)
247
+ copied["items"] = [_select_fields_from_row(item, selected) for item in value["items"]]
248
+ return copied
249
+ return _select_fields_from_row(value, selected)
250
+ return value
251
+
252
+
253
+ def _select_fields_from_row(value: Any, selected: list[str]) -> Any:
254
+ if not isinstance(value, dict):
255
+ return value
256
+ return {key: value.get(key) for key in selected if key in value}
257
+
258
+
259
+ def _to_output_rows(value: Any) -> list[Any]:
260
+ if isinstance(value, list):
261
+ return value
262
+ if isinstance(value, dict) and "items" in value and isinstance(value["items"], list):
263
+ return value["items"]
264
+ return [value]
265
+
266
+
267
+ def _print_csv(value: Any) -> None:
268
+ rows = _normalize_tabular_rows(_to_output_rows(value))
269
+ if not rows:
270
+ return
271
+ fieldnames = _discover_fieldnames(rows)
272
+ writer = csv.DictWriter(sys.stdout, fieldnames=fieldnames, lineterminator="\n")
273
+ writer.writeheader()
274
+ for row in rows:
275
+ writer.writerow({field: _stringify_cell(row.get(field)) for field in fieldnames})
276
+
277
+
278
+ def _print_table(value: Any) -> None:
279
+ rows = _normalize_tabular_rows(_to_output_rows(value))
280
+ if not rows:
281
+ return
282
+ columns = _discover_fieldnames(rows)
283
+ matrix: list[list[str]] = [[_stringify_cell(row.get(column)) for column in columns] for row in rows]
284
+ widths = [len(column) for column in columns]
285
+ for row in matrix:
286
+ for idx, cell in enumerate(row):
287
+ widths[idx] = max(widths[idx], len(cell))
288
+ header = " | ".join(columns[idx].ljust(widths[idx]) for idx in range(len(columns)))
289
+ separator = "-+-".join("-" * widths[idx] for idx in range(len(columns)))
290
+ print(header)
291
+ print(separator)
292
+ for row in matrix:
293
+ print(" | ".join(row[idx].ljust(widths[idx]) for idx in range(len(columns))))
294
+
295
+
296
+ def _normalize_tabular_rows(rows: list[Any]) -> list[dict[str, Any]]:
297
+ normalized: list[dict[str, Any]] = []
298
+ for row in rows:
299
+ if isinstance(row, dict):
300
+ normalized.append(row)
301
+ else:
302
+ normalized.append({"value": row})
303
+ return normalized
304
+
305
+
306
+ def _discover_fieldnames(rows: list[dict[str, Any]]) -> list[str]:
307
+ fieldnames: list[str] = []
308
+ for row in rows:
309
+ for key in row:
310
+ if key not in fieldnames:
311
+ fieldnames.append(key)
312
+ return fieldnames
313
+
314
+
315
+ def _stringify_cell(value: Any) -> str:
316
+ if value is None:
317
+ return ""
318
+ if isinstance(value, (str, int, float, bool)):
319
+ return str(value)
320
+ return json.dumps(value, ensure_ascii=False)
321
+
322
+
323
+ def _resolve_many_inputs(
324
+ *,
325
+ single_value: str | None,
326
+ input_file: str | None,
327
+ use_stdin: bool,
328
+ parser: argparse.ArgumentParser,
329
+ ) -> list[str]:
330
+ values: list[str] = []
331
+ if single_value:
332
+ values.append(single_value)
333
+ if input_file:
334
+ try:
335
+ text = Path(input_file).read_text(encoding="utf-8")
336
+ except OSError as exc:
337
+ parser.error(f"Cannot read --input-file {input_file}: {exc}")
338
+ values.extend(_split_lines(text))
339
+ if use_stdin:
340
+ values.extend(_split_lines(sys.stdin.read()))
341
+ if not values:
342
+ parser.error("Provide a positional value, --input-file, or --stdin.")
343
+ return values
344
+
345
+
346
+ def _split_lines(text: str) -> list[str]:
347
+ values: list[str] = []
348
+ for raw_line in text.splitlines():
349
+ value = raw_line.strip()
350
+ if value and not value.startswith("#"):
351
+ values.append(value)
352
+ return values
353
+
354
+
355
+ def _parse_fields(fields: str | None) -> list[str]:
356
+ if fields is None:
357
+ return []
358
+ return [item.strip() for item in fields.split(",") if item.strip()]
359
+
360
+
361
+ def _pages_type(value: str) -> int | None:
362
+ if value.lower() == "all":
363
+ return None
364
+ num = int(value)
365
+ if num < 1:
366
+ raise argparse.ArgumentTypeError("pages must be >= 1 or 'all'")
367
+ return num
368
+
369
+
370
+ if __name__ == "__main__":
371
+ raise SystemExit(main())