pppandas 0.1.0__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.
- ppandas/__init__.py +3 -0
- ppandas/cli.py +241 -0
- pppandas-0.1.0.dist-info/METADATA +51 -0
- pppandas-0.1.0.dist-info/RECORD +8 -0
- pppandas-0.1.0.dist-info/WHEEL +5 -0
- pppandas-0.1.0.dist-info/entry_points.txt +2 -0
- pppandas-0.1.0.dist-info/licenses/LICENSE +17 -0
- pppandas-0.1.0.dist-info/top_level.txt +1 -0
ppandas/__init__.py
ADDED
ppandas/cli.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Command-line interface for browsing a GitHub repository."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import urllib.error
|
|
9
|
+
import urllib.parse
|
|
10
|
+
import urllib.request
|
|
11
|
+
import zipfile
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
DEFAULT_REPO = "PratikChavan05/LP3"
|
|
16
|
+
API_BASE = "https://api.github.com"
|
|
17
|
+
RAW_BASE = "https://raw.githubusercontent.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def request_bytes(url: str) -> bytes:
|
|
21
|
+
"""Fetch a URL with a descriptive user-agent."""
|
|
22
|
+
req = urllib.request.Request(
|
|
23
|
+
url,
|
|
24
|
+
headers={
|
|
25
|
+
"User-Agent": "ppandas-cli/0.1.0",
|
|
26
|
+
"Accept": "application/vnd.github+json",
|
|
27
|
+
},
|
|
28
|
+
)
|
|
29
|
+
try:
|
|
30
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
31
|
+
return response.read()
|
|
32
|
+
except urllib.error.HTTPError as exc:
|
|
33
|
+
if exc.code == 404:
|
|
34
|
+
raise RuntimeError(f"Not found: {url}") from exc
|
|
35
|
+
if exc.code == 403:
|
|
36
|
+
raise RuntimeError(
|
|
37
|
+
"GitHub rate limit or access restriction. Try again later, "
|
|
38
|
+
"or configure a GitHub token using PPANDAS_GITHUB_TOKEN."
|
|
39
|
+
) from exc
|
|
40
|
+
raise RuntimeError(f"GitHub request failed (HTTP {exc.code}): {url}") from exc
|
|
41
|
+
except urllib.error.URLError as exc:
|
|
42
|
+
raise RuntimeError(f"Network error: {exc.reason}") from exc
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def headers_with_token() -> dict[str, str]:
|
|
46
|
+
token = os.environ.get("PPANDAS_GITHUB_TOKEN")
|
|
47
|
+
headers = {"User-Agent": "ppandas-cli/0.1.0", "Accept": "application/vnd.github+json"}
|
|
48
|
+
if token:
|
|
49
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
50
|
+
return headers
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def github_json(url: str) -> Any:
|
|
54
|
+
req = urllib.request.Request(url, headers=headers_with_token())
|
|
55
|
+
try:
|
|
56
|
+
with urllib.request.urlopen(req, timeout=30) as response:
|
|
57
|
+
return json.loads(response.read().decode("utf-8"))
|
|
58
|
+
except urllib.error.HTTPError as exc:
|
|
59
|
+
if exc.code == 404:
|
|
60
|
+
raise RuntimeError(f"Repository, branch, or path not found: {url}") from exc
|
|
61
|
+
if exc.code == 403:
|
|
62
|
+
raise RuntimeError(
|
|
63
|
+
"GitHub API rate limit/access restriction. Set PPANDAS_GITHUB_TOKEN "
|
|
64
|
+
"to a GitHub token if needed."
|
|
65
|
+
) from exc
|
|
66
|
+
raise RuntimeError(f"GitHub API request failed (HTTP {exc.code}): {url}") from exc
|
|
67
|
+
except urllib.error.URLError as exc:
|
|
68
|
+
raise RuntimeError(f"Network error: {exc.reason}") from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def parse_repo(value: str) -> str:
|
|
72
|
+
value = value.strip().rstrip("/")
|
|
73
|
+
if value.startswith("https://github.com/"):
|
|
74
|
+
value = value[len("https://github.com/"):]
|
|
75
|
+
value = value.removesuffix(".git")
|
|
76
|
+
parts = value.split("/")
|
|
77
|
+
if len(parts) < 2 or not all(parts[:2]):
|
|
78
|
+
raise argparse.ArgumentTypeError(
|
|
79
|
+
"Repository must be OWNER/REPO or a GitHub repository URL."
|
|
80
|
+
)
|
|
81
|
+
return f"{parts[0]}/{parts[1]}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def get_tree(repo: str, branch: str) -> tuple[str, list[dict[str, Any]]]:
|
|
85
|
+
encoded_branch = urllib.parse.quote(branch, safe="")
|
|
86
|
+
data = github_json(f"{API_BASE}/repos/{repo}/git/trees/{encoded_branch}?recursive=1")
|
|
87
|
+
if data.get("truncated"):
|
|
88
|
+
print("Warning: GitHub returned a truncated file tree.", file=sys.stderr)
|
|
89
|
+
return data.get("sha", ""), data.get("tree", [])
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def cmd_list(args: argparse.Namespace) -> int:
|
|
93
|
+
repo = parse_repo(args.repo)
|
|
94
|
+
_, tree = get_tree(repo, args.branch)
|
|
95
|
+
entries = [item for item in tree if item.get("type") == "blob"]
|
|
96
|
+
for item in entries:
|
|
97
|
+
print(item["path"])
|
|
98
|
+
print(f"\n{len(entries)} files")
|
|
99
|
+
return 0
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def cmd_show(args: argparse.Namespace) -> int:
|
|
103
|
+
repo = parse_repo(args.repo)
|
|
104
|
+
path = args.path.lstrip("/")
|
|
105
|
+
_, tree = get_tree(repo, args.branch)
|
|
106
|
+
match = next(
|
|
107
|
+
(item for item in tree if item.get("type") == "blob" and item.get("path") == path),
|
|
108
|
+
None,
|
|
109
|
+
)
|
|
110
|
+
if not match:
|
|
111
|
+
raise RuntimeError(f"File not found in {repo}@{args.branch}: {path}")
|
|
112
|
+
if match.get("size", 0) > args.max_bytes:
|
|
113
|
+
raise RuntimeError(
|
|
114
|
+
f"File is {match['size']} bytes, exceeding the {args.max_bytes}-byte limit. "
|
|
115
|
+
"Use --max-bytes to increase the limit."
|
|
116
|
+
)
|
|
117
|
+
encoded_path = "/".join(urllib.parse.quote(part, safe="") for part in path.split("/"))
|
|
118
|
+
url = f"{RAW_BASE}/{repo}/{urllib.parse.quote(args.branch, safe='')}/{encoded_path}"
|
|
119
|
+
content = request_bytes(url)
|
|
120
|
+
try:
|
|
121
|
+
print(content.decode("utf-8"))
|
|
122
|
+
except UnicodeDecodeError as exc:
|
|
123
|
+
raise RuntimeError("This file is not UTF-8 text and cannot be displayed.") from exc
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
128
|
+
repo = parse_repo(args.repo)
|
|
129
|
+
_, tree = get_tree(repo, args.branch)
|
|
130
|
+
files = [
|
|
131
|
+
item for item in tree
|
|
132
|
+
if item.get("type") == "blob"
|
|
133
|
+
and (not args.extension or item["path"].lower().endswith(args.extension.lower()))
|
|
134
|
+
and item.get("size", 0) <= args.max_bytes
|
|
135
|
+
]
|
|
136
|
+
needle = args.keyword.casefold()
|
|
137
|
+
found = 0
|
|
138
|
+
for item in files:
|
|
139
|
+
path = item["path"]
|
|
140
|
+
encoded_path = "/".join(urllib.parse.quote(part, safe="") for part in path.split("/"))
|
|
141
|
+
url = f"{RAW_BASE}/{repo}/{urllib.parse.quote(args.branch, safe='')}/{encoded_path}"
|
|
142
|
+
try:
|
|
143
|
+
text = request_bytes(url).decode("utf-8")
|
|
144
|
+
except (RuntimeError, UnicodeDecodeError):
|
|
145
|
+
continue
|
|
146
|
+
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
147
|
+
if needle in line.casefold():
|
|
148
|
+
print(f"{path}:{line_number}: {line.strip()}")
|
|
149
|
+
found += 1
|
|
150
|
+
if found >= args.limit:
|
|
151
|
+
print(f"Search result limit reached ({args.limit}).")
|
|
152
|
+
return 0
|
|
153
|
+
if found == 0:
|
|
154
|
+
print("No matches found.")
|
|
155
|
+
else:
|
|
156
|
+
print(f"\n{found} match(es)")
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def cmd_download(args: argparse.Namespace) -> int:
|
|
161
|
+
repo = parse_repo(args.repo)
|
|
162
|
+
branch = args.branch
|
|
163
|
+
archive_url = f"https://github.com/{repo}/archive/refs/heads/{urllib.parse.quote(branch, safe='')}.zip"
|
|
164
|
+
archive = request_bytes(archive_url)
|
|
165
|
+
destination = Path(args.output).expanduser().resolve()
|
|
166
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
with zipfile.ZipFile(__import__("io").BytesIO(archive)) as zf:
|
|
168
|
+
# Extract into a single repository folder, rejecting unsafe paths.
|
|
169
|
+
root = destination.resolve()
|
|
170
|
+
for member in zf.infolist():
|
|
171
|
+
target = (destination / member.filename).resolve()
|
|
172
|
+
if target != root and root not in target.parents:
|
|
173
|
+
raise RuntimeError("Archive contains an unsafe path; refusing to extract.")
|
|
174
|
+
zf.extractall(destination)
|
|
175
|
+
print(f"Downloaded {repo}@{branch} to {destination}")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def cmd_update(args: argparse.Namespace) -> int:
|
|
180
|
+
# For this CLI, update refreshes the repository by downloading its latest archive.
|
|
181
|
+
return cmd_download(args)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def add_repo_options(parser: argparse.ArgumentParser) -> None:
|
|
185
|
+
parser.add_argument(
|
|
186
|
+
"--repo", default=DEFAULT_REPO,
|
|
187
|
+
help=f"GitHub repository as OWNER/REPO or URL (default: {DEFAULT_REPO})",
|
|
188
|
+
)
|
|
189
|
+
parser.add_argument("--branch", default="main", help="Branch or tag (default: main)")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
193
|
+
parser = argparse.ArgumentParser(
|
|
194
|
+
prog="ppandas",
|
|
195
|
+
description="Browse and download the LP3 GitHub repository from your terminal.",
|
|
196
|
+
)
|
|
197
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
198
|
+
|
|
199
|
+
list_parser = subparsers.add_parser("list", help="List repository files")
|
|
200
|
+
add_repo_options(list_parser)
|
|
201
|
+
list_parser.set_defaults(func=cmd_list)
|
|
202
|
+
|
|
203
|
+
show_parser = subparsers.add_parser("show", help="Print one file's source code")
|
|
204
|
+
show_parser.add_argument("path", help="Path relative to the repository root")
|
|
205
|
+
add_repo_options(show_parser)
|
|
206
|
+
show_parser.add_argument("--max-bytes", type=int, default=1_000_000)
|
|
207
|
+
show_parser.set_defaults(func=cmd_show)
|
|
208
|
+
|
|
209
|
+
search_parser = subparsers.add_parser("search", help="Search text across repository files")
|
|
210
|
+
search_parser.add_argument("keyword", help="Text to search for")
|
|
211
|
+
add_repo_options(search_parser)
|
|
212
|
+
search_parser.add_argument("--extension", help="Limit to an extension, e.g. .py or .cpp")
|
|
213
|
+
search_parser.add_argument("--max-bytes", type=int, default=500_000)
|
|
214
|
+
search_parser.add_argument("--limit", type=int, default=100)
|
|
215
|
+
search_parser.set_defaults(func=cmd_search)
|
|
216
|
+
|
|
217
|
+
download_parser = subparsers.add_parser("download", help="Download and extract repository ZIP")
|
|
218
|
+
add_repo_options(download_parser)
|
|
219
|
+
download_parser.add_argument("--output", default=".", help="Directory to extract into")
|
|
220
|
+
download_parser.set_defaults(func=cmd_download)
|
|
221
|
+
|
|
222
|
+
update_parser = subparsers.add_parser("update", help="Download the latest repository archive")
|
|
223
|
+
add_repo_options(update_parser)
|
|
224
|
+
update_parser.add_argument("--output", default=".", help="Directory to extract into")
|
|
225
|
+
update_parser.set_defaults(func=cmd_update)
|
|
226
|
+
|
|
227
|
+
return parser
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def main() -> int:
|
|
231
|
+
parser = build_parser()
|
|
232
|
+
args = parser.parse_args()
|
|
233
|
+
try:
|
|
234
|
+
return int(args.func(args))
|
|
235
|
+
except (RuntimeError, OSError, zipfile.BadZipFile) as exc:
|
|
236
|
+
print(f"ppandas: error: {exc}", file=sys.stderr)
|
|
237
|
+
return 1
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
if __name__ == "__main__":
|
|
241
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pppandas
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A terminal tool to browse and download the PratikChavan05/LP3 GitHub repository
|
|
5
|
+
Author: PratikChavan05
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.9
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Dynamic: license-file
|
|
11
|
+
|
|
12
|
+
# ppandas
|
|
13
|
+
|
|
14
|
+
`ppandas` is a terminal CLI for listing, viewing, searching, and downloading the public
|
|
15
|
+
GitHub repository `PratikChavan05/LP3`.
|
|
16
|
+
|
|
17
|
+
## Install locally
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
python3 -m venv .venv
|
|
21
|
+
source .venv/bin/activate
|
|
22
|
+
python -m pip install --upgrade pip
|
|
23
|
+
python -m pip install .
|
|
24
|
+
ppandas --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
ppandas list
|
|
31
|
+
ppandas show path/to/file.cpp
|
|
32
|
+
ppandas search "binary search"
|
|
33
|
+
ppandas search "main" --extension .cpp
|
|
34
|
+
ppandas download --output ./downloads
|
|
35
|
+
ppandas update --output ./downloads
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Override the repository or branch:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
ppandas list --repo OWNER/REPO --branch main
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`show` and `search` fetch files from GitHub when used, so an internet connection is required.
|
|
45
|
+
The default repository is public. For higher GitHub API rate limits, set
|
|
46
|
+
`PPANDAS_GITHUB_TOKEN` to a GitHub token in your shell. Do not commit a token to source control.
|
|
47
|
+
|
|
48
|
+
## Publish to PyPI
|
|
49
|
+
|
|
50
|
+
Check package-name availability and complete the PyPI account setup first. Then build and
|
|
51
|
+
upload a release using the official Python packaging instructions.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
ppandas/__init__.py,sha256=MT07VHlxgjA1iutLdAHzBIUNWIDPsRhS1N1xdwd9O8g,95
|
|
2
|
+
ppandas/cli.py,sha256=kQ6mthdmgaeFObIBXpuAtcfhC3FRahBGvxjR2lHRiGI,9334
|
|
3
|
+
pppandas-0.1.0.dist-info/licenses/LICENSE,sha256=IAMcIK9VEEi6Ay3oIgiuaQdTVCpIbLG0kOQu0ZRKv64,812
|
|
4
|
+
pppandas-0.1.0.dist-info/METADATA,sha256=QBukhkYy4yHCG7f-Q6RN6aWOYnmkS7yOBqqAc7AO6AU,1330
|
|
5
|
+
pppandas-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
6
|
+
pppandas-0.1.0.dist-info/entry_points.txt,sha256=uSh1-ssG4mzu8PsDDIrc_QEByGLPMORqkxqxrogSDaE,45
|
|
7
|
+
pppandas-0.1.0.dist-info/top_level.txt,sha256=5CXoUq_TlC3Fp44175sX7zfKcI6sozJapj2nNT75Vf0,8
|
|
8
|
+
pppandas-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PratikChavan05
|
|
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ppandas
|