codefetch-cli 1.2.2__py3-none-any.whl → 1.2.4__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.
codefetch/__init__.py CHANGED
@@ -1,5 +1,160 @@
1
1
  """
2
- CodeFetch - CLI tool to fetch code files from GitHub repositories.
2
+ CodeFetch - Easily preview and download code files from GitHub repositories.
3
+
4
+ Usage in Python:
5
+ import codefetch as cf
6
+
7
+ # 1. Fetch file content as string:
8
+ content = cf.get("torvalds/linux", "Makefile")
9
+ # or shorthand target string:
10
+ content = cf.get("torvalds/linux/Makefile")
11
+
12
+ # 2. Download file to local disk:
13
+ cf.download("torvalds/linux", "Makefile", output="linux_makefile.txt")
14
+
15
+ # 3. List available code files:
16
+ files = cf.list("torvalds/linux")
3
17
  """
4
18
 
5
- __version__ = "1.2.2"
19
+ __version__ = "1.2.4"
20
+
21
+
22
+ def get(target, filename=None, branch=None):
23
+ """
24
+ Fetch and return the text content of a file from GitHub.
25
+
26
+ Args:
27
+ target: 'owner/repo' or full path 'owner/repo/path/to/file' or full GitHub URL.
28
+ filename: Optional path to file in repo if target is 'owner/repo'.
29
+ branch: Branch name (default: None, automatically detects repo default branch).
30
+
31
+ Returns:
32
+ str: File content.
33
+
34
+ Raises:
35
+ FileNotFoundError: If the file does not exist in the repository.
36
+ ValueError: If arguments cannot be resolved into a repository and filename.
37
+ """
38
+ import urllib.request
39
+ from .cli import (
40
+ get_default_branch,
41
+ check_branch_exists,
42
+ fetch_raw_single,
43
+ get_repo_tree,
44
+ resolve_file_in_tree,
45
+ parse_repo_identifier,
46
+ resolve_invocation,
47
+ )
48
+
49
+ if filename:
50
+ owner, repo = parse_repo_identifier(target)
51
+ file_path = filename.strip("/\\")
52
+ else:
53
+ owner, repo, resolved_branch, file_path = resolve_invocation(
54
+ [target], None, None, branch, None, None
55
+ )
56
+ if resolved_branch:
57
+ branch = resolved_branch
58
+
59
+ if not owner or not repo or not file_path:
60
+ raise ValueError(f"Could not resolve owner, repo, and filename from target='{target}', filename='{filename}'")
61
+
62
+ target_branch = branch
63
+ if not target_branch:
64
+ target_branch = get_default_branch(owner, repo)
65
+ elif not check_branch_exists(owner, repo, target_branch):
66
+ target_branch = get_default_branch(owner, repo)
67
+
68
+ # 1. Try fast direct raw fetch
69
+ content = fetch_raw_single(owner, repo, target_branch, file_path)
70
+ if content is not None:
71
+ return content
72
+
73
+ # 2. Try repository tree search
74
+ tree_data = get_repo_tree(owner, repo, target_branch)
75
+ matched = resolve_file_in_tree(tree_data, file_path)
76
+ if matched:
77
+ url = f"https://raw.githubusercontent.com/{owner}/{repo}/{target_branch}/{matched}"
78
+ req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
79
+ with urllib.request.urlopen(req, timeout=15) as resp:
80
+ return resp.read().decode("utf-8")
81
+
82
+ raise FileNotFoundError(f"File '{file_path}' not found in {owner}/{repo} ({target_branch})")
83
+
84
+
85
+ def download(target, filename=None, output=None, branch=None):
86
+ """
87
+ Download a file from GitHub and save it locally.
88
+
89
+ Args:
90
+ target: 'owner/repo' or full path 'owner/repo/path/to/file'.
91
+ filename: Optional path to file in repo if target is 'owner/repo'.
92
+ output: Destination filename/path on local disk (default: original filename).
93
+ branch: Branch name (default: None, automatically detects repo default branch).
94
+
95
+ Returns:
96
+ str: Path to the downloaded file.
97
+ """
98
+ content = get(target, filename=filename, branch=branch)
99
+ dest_name = output
100
+ if not dest_name:
101
+ resolved_file = filename if filename else target.rsplit("/", 1)[-1]
102
+ dest_name = resolved_file.rsplit("/", 1)[-1]
103
+
104
+ with open(dest_name, "w", encoding="utf-8") as f:
105
+ f.write(content)
106
+
107
+ return dest_name
108
+
109
+
110
+ def list(target, branch=None):
111
+ """
112
+ List supported code files in a repository.
113
+
114
+ Args:
115
+ target: 'owner/repo' or repository URL.
116
+ branch: Branch name (default: None, automatically detects repo default branch).
117
+
118
+ Returns:
119
+ list[str]: Relative paths of code files in the repository.
120
+ """
121
+ from .cli import (
122
+ parse_repo_identifier,
123
+ get_default_branch,
124
+ check_branch_exists,
125
+ get_repo_tree,
126
+ CODE_EXTENSIONS,
127
+ NAMED_FILES,
128
+ )
129
+
130
+ owner, repo = parse_repo_identifier(target)
131
+ if not owner or not repo:
132
+ raise ValueError(f"Invalid repository identifier: '{target}'")
133
+
134
+ target_branch = branch
135
+ if not target_branch or not check_branch_exists(owner, repo, target_branch):
136
+ target_branch = get_default_branch(owner, repo)
137
+
138
+ tree_data = get_repo_tree(owner, repo, target_branch)
139
+ files = []
140
+ for item in tree_data.get("tree", []):
141
+ if item.get("type") != "blob":
142
+ continue
143
+ path = item.get("path", "")
144
+ base_name = path.rsplit("/", 1)[-1].lower()
145
+ suffix = ""
146
+ if "." in base_name:
147
+ suffix = "." + base_name.rsplit(".", 1)[-1]
148
+ if suffix in CODE_EXTENSIONS or base_name in NAMED_FILES:
149
+ files.append(path)
150
+
151
+ files.sort(key=str.lower)
152
+ return files
153
+
154
+
155
+ __all__ = [
156
+ "__version__",
157
+ "get",
158
+ "download",
159
+ "list",
160
+ ]
codefetch/cli.py CHANGED
@@ -14,10 +14,12 @@ from pathlib import Path
14
14
 
15
15
  DEFAULT_OWNER = None
16
16
  DEFAULT_REPO = None
17
- DEFAULT_BRANCH = "main"
17
+ DEFAULT_BRANCH = None
18
18
 
19
19
  CONFIG_FILE = Path.home() / ".codefetch.json"
20
20
 
21
+ __version__ = "1.2.4"
22
+
21
23
  # Supported code, data, and config extensions
22
24
  CODE_EXTENSIONS = {
23
25
  # Data & Tables
@@ -56,85 +58,86 @@ CodeFetch - Download single code files from GitHub without cloning entire repos.
56
58
 
57
59
  USAGE:
58
60
  codefetch <owner>/<repo> <filename> Download a file from any GitHub repository
59
- codefetch <filename> Download a file (requires default repository)
60
-
61
- QUICK EXAMPLES:
62
- Download a file:
63
- codefetch torvalds/linux Makefile Save Makefile to current directory
64
- codefetch owner/repo main.py Save main.py from a repository
65
- codefetch owner/repo data.csv -o out.csv Save with a custom output filename
66
-
67
- View code in terminal (without downloading):
68
- codefetch -s owner/repo main.py Display main.py in terminal
69
- codefetch -s torvalds/linux Makefile Display Makefile in terminal
70
-
71
- Browse files and repositories:
72
- codefetch -l owner/repo List all code files in a repository
73
- codefetch -R -u <username> List all public repositories of a user
74
- codefetch -R <username> List all public repositories of a user
75
-
76
- SETTINGS & DEFAULTS (Optional):
77
- codefetch --set-default <owner/repo> Set a permanent default repository
78
- codefetch --set-default-user <username> Set a permanent default GitHub user
79
- codefetch --config View current saved settings
80
- codefetch --reset-config Clear saved settings
81
-
82
- OPTIONS:
83
- -s, --show View file contents in terminal instead of saving
84
- -o, --output <file> Custom filename or path to save the downloaded file
85
- -l, --list [repo] List available code files in a repository
86
- -R, --repos [user] List all public repositories for a user
87
- -r, --repo <name> Specify repository name or URL
88
- -u, --user <name> Specify GitHub username
89
- -b, --branch <name> Specify branch name (default: main)
90
- -v, --version Show version number
91
- -h, --help Show this help guide
61
+ codefetch <filename> Download a file (uses saved default repo)
62
+ codefetch -s <owner>/<repo> <filename> Preview file in terminal without saving
63
+ codefetch -l <owner>/<repo> List all code files in a repository
64
+ codefetch -R <username> List public repositories for a user
65
+
66
+ POPULAR EXAMPLES:
67
+ # Download from any repository:
68
+ codefetch owner/repo main.py
69
+ codefetch torvalds/linux Makefile
70
+
71
+ # Preview code directly in terminal:
72
+ codefetch -s owner/repo app.js
73
+ codefetch -s torvalds/linux Makefile
74
+
75
+ # Save with a custom name or path:
76
+ codefetch owner/repo data.csv -o my_data.csv
77
+
78
+ # Explore repositories and files:
79
+ codefetch -l torvalds/linux # list files in a repo
80
+ codefetch -R torvalds # list repos belonging to user
81
+
82
+ SETTING A DEFAULT REPOSITORY (OPTIONAL):
83
+ If you frequently work with the same repo, you can set it as default:
84
+ codefetch --set-default owner/repo # sets default repo
85
+ codefetch script.py # now you don't need to type owner/repo!
86
+ codefetch --config # view active configuration
87
+ codefetch --reset-config # reset all defaults
88
+
89
+ FLAGS & OPTIONS:
90
+ -s, --show Print file content to terminal instead of downloading
91
+ -o, --output <file> Specify output filename or path
92
+ -l, --list List available code files in the repository
93
+ -R, --repos List public repositories for a GitHub user
94
+ -r, --repo <repo> Specify repository name or URL
95
+ -u, --user <user> Specify GitHub username / organization
96
+ -b, --branch <branch> Specify branch name (default: repository default branch)
97
+ --set-default <repo> Save default repository (e.g. owner/repo)
98
+ --set-default-user <user> Save default user/organization
99
+ --config Show current saved configuration
100
+ --reset-config Reset configuration back to defaults
101
+ -v, --version Show version number
102
+ -h, --help Show this help message
92
103
  """
93
104
 
105
+ _REPO_DEFAULT_BRANCH_CACHE = {}
106
+ _REPO_BRANCHES_CACHE = {}
107
+
94
108
 
95
109
  def load_config():
96
- """Load configuration from environment variables and local config file."""
97
- config = {
98
- "owner": os.environ.get("CODEFETCH_USER", DEFAULT_OWNER),
99
- "repo": os.environ.get("CODEFETCH_REPO", DEFAULT_REPO),
100
- "branch": os.environ.get("CODEFETCH_BRANCH", DEFAULT_BRANCH),
101
- }
110
+ """Load user configuration from ~/.codefetch.json."""
102
111
  if CONFIG_FILE.exists():
103
112
  try:
104
113
  with open(CONFIG_FILE, "r", encoding="utf-8") as f:
105
- saved = json.load(f)
106
- if isinstance(saved, dict):
107
- if saved.get("owner"):
108
- config["owner"] = saved["owner"]
109
- if saved.get("repo"):
110
- config["repo"] = saved["repo"]
111
- if saved.get("branch"):
112
- config["branch"] = saved["branch"]
114
+ data = json.load(f)
115
+ return {
116
+ "owner": data.get("owner"),
117
+ "repo": data.get("repo"),
118
+ }
113
119
  except Exception:
114
- pass
115
- return config
120
+ return {}
121
+ return {}
116
122
 
117
123
 
118
- def save_config(owner=None, repo=None, branch=None):
119
- """Save configuration to ~/.codefetch.json."""
120
- config = load_config()
121
- if owner:
122
- config["owner"] = owner
123
- if repo:
124
- config["repo"] = repo
125
- if branch:
126
- config["branch"] = branch
124
+ def save_config(owner=None, repo=None):
125
+ """Save user configuration to ~/.codefetch.json (no branch saved in config)."""
126
+ cfg = load_config()
127
+ if owner is not None:
128
+ cfg["owner"] = owner
129
+ if repo is not None:
130
+ cfg["repo"] = repo
131
+
127
132
  try:
128
133
  with open(CONFIG_FILE, "w", encoding="utf-8") as f:
129
- json.dump(config, f, indent=2)
130
- return True
134
+ json.dump(cfg, f, indent=2)
131
135
  except Exception as e:
132
- print(f"Error saving configuration: {e}")
133
- return False
136
+ print(f"Warning: Could not save configuration: {e}")
134
137
 
135
138
 
136
139
  def get_headers():
137
- """Get HTTP headers for GitHub requests."""
140
+ """Build request headers, attaching GITHUB_TOKEN if present in environment."""
138
141
  headers = {
139
142
  "User-Agent": "codefetch",
140
143
  "Accept": "application/vnd.github+json",
@@ -146,10 +149,10 @@ def get_headers():
146
149
 
147
150
 
148
151
  def get_json(url):
149
- """Fetch and decode JSON from a URL."""
150
- request = urllib.request.Request(url, headers=get_headers())
151
- with urllib.request.urlopen(request, timeout=15) as response:
152
- return json.loads(response.read().decode("utf-8"))
152
+ """Fetch JSON from GitHub API with rate limit handling."""
153
+ req = urllib.request.Request(url, headers=get_headers())
154
+ with urllib.request.urlopen(req, timeout=15) as resp:
155
+ return json.loads(resp.read().decode("utf-8"))
153
156
 
154
157
 
155
158
  def parse_repo_identifier(repo_str, default_owner=None):
@@ -170,37 +173,112 @@ def parse_repo_identifier(repo_str, default_owner=None):
170
173
  return default_owner, repo_str.removesuffix(".git")
171
174
 
172
175
 
173
- def get_repo_tree(owner, repo, branch="main"):
174
- """Fetch the Git tree for a repository, with fallback to master if main is absent."""
175
- url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
176
+ def get_default_branch(owner, repo):
177
+ """
178
+ Get the default branch of a repository (e.g. 'main', 'master', 'unstable').
179
+ Queries GitHub API and caches the result. Falls back to probing if rate limited.
180
+ """
181
+ key = f"{owner.lower()}/{repo.lower()}"
182
+ if key in _REPO_DEFAULT_BRANCH_CACHE:
183
+ return _REPO_DEFAULT_BRANCH_CACHE[key]
184
+
185
+ try:
186
+ url = f"https://api.github.com/repos/{owner}/{repo}"
187
+ data = get_json(url)
188
+ def_b = data.get("default_branch")
189
+ if def_b:
190
+ _REPO_DEFAULT_BRANCH_CACHE[key] = def_b
191
+ return def_b
192
+ except Exception:
193
+ pass
194
+
195
+ # Fast fallback probe via CDN if API is unavailable or rate-limited
196
+ for b in ("main", "master"):
197
+ try:
198
+ req = urllib.request.Request(
199
+ f"https://raw.githubusercontent.com/{owner}/{repo}/{b}/README.md",
200
+ headers=get_headers(),
201
+ )
202
+ with urllib.request.urlopen(req, timeout=5):
203
+ _REPO_DEFAULT_BRANCH_CACHE[key] = b
204
+ return b
205
+ except Exception:
206
+ continue
207
+
208
+ return "main"
209
+
210
+
211
+ def get_repo_branches(owner, repo):
212
+ """Fetch the list of all branch names in a repository."""
213
+ key = f"{owner.lower()}/{repo.lower()}"
214
+ if key in _REPO_BRANCHES_CACHE:
215
+ return _REPO_BRANCHES_CACHE[key]
216
+
217
+ try:
218
+ url = f"https://api.github.com/repos/{owner}/{repo}/branches?per_page=100"
219
+ data = get_json(url)
220
+ branches = [b["name"] for b in data if isinstance(b, dict) and b.get("name")]
221
+ if branches:
222
+ _REPO_BRANCHES_CACHE[key] = branches
223
+ return branches
224
+ except Exception:
225
+ pass
226
+ return []
227
+
228
+
229
+ def check_branch_exists(owner, repo, branch):
230
+ """Check if a specific branch exists in a repository."""
231
+ if not branch:
232
+ return False
233
+ branches = get_repo_branches(owner, repo)
234
+ if branches:
235
+ return branch in branches
176
236
  try:
177
- return get_json(url)
237
+ get_json(f"https://api.github.com/repos/{owner}/{repo}/branches/{branch}")
238
+ return True
178
239
  except urllib.error.HTTPError as e:
179
- if e.code == 404 and branch == "main":
180
- url_master = f"https://api.github.com/repos/{owner}/{repo}/git/trees/master?recursive=1"
181
- try:
182
- return get_json(url_master)
183
- except Exception:
184
- pass
240
+ if e.code == 404:
241
+ return False
242
+ return True
243
+ except Exception:
244
+ return True
245
+
246
+
247
+ def fetch_raw_single(owner, repo, branch, clean_filename):
248
+ """
249
+ Direct raw fetch from raw.githubusercontent.com for a specific branch.
250
+ Returns content string or None if 404.
251
+ """
252
+ url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{clean_filename}"
253
+ try:
254
+ req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
255
+ with urllib.request.urlopen(req, timeout=15) as resp:
256
+ return resp.read().decode("utf-8")
257
+ except urllib.error.HTTPError as e:
258
+ if e.code == 404:
259
+ return None
260
+ raise
261
+ except UnicodeDecodeError:
185
262
  raise
186
263
 
187
264
 
265
+ def get_repo_tree(owner, repo, branch):
266
+ """Fetch the Git tree for a repository on a given branch."""
267
+ url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
268
+ return get_json(url)
269
+
270
+
188
271
  def get_user_repos(owner):
189
- """Fetch public repositories for a given GitHub owner."""
272
+ """Fetch public repositories list for a user."""
190
273
  url = f"https://api.github.com/users/{owner}/repos?per_page=100&sort=updated"
191
- try:
192
- return get_json(url)
193
- except Exception:
194
- return []
274
+ return get_json(url)
195
275
 
196
276
 
197
277
  def list_repos(owner=None, default_repo=None):
198
- """List all public repositories for the owner."""
278
+ """List all public repositories for a GitHub user with clean spacing."""
199
279
  if not owner:
200
- print("Error: No GitHub user specified.\n")
201
- print("Usage:")
202
- print(" codefetch -R <username>")
203
- print(" codefetch -R -u <username>")
280
+ print("Error: No GitHub username specified.")
281
+ print("Usage: codefetch --repos <username>")
204
282
  return
205
283
 
206
284
  try:
@@ -209,19 +287,21 @@ def list_repos(owner=None, default_repo=None):
209
287
  print(f"No public repositories found for user '{owner}'.")
210
288
  return
211
289
 
212
- print(f"Repositories for '{owner}':\n")
213
- for r in repos:
290
+ print(f"Public repositories for '{owner}' ({len(repos)} total):\n")
291
+ for i, r in enumerate(repos, 1):
214
292
  name = r.get("name", "")
215
- desc = r.get("description") or ""
293
+ desc = (r.get("description") or "").strip()
216
294
  is_def = " (default)" if default_repo and name.lower() == default_repo.lower() else ""
217
- marker = "*" if is_def else "-"
218
- desc_text = f" - {desc}" if desc else ""
219
- print(f" {marker} {name}{is_def}{desc_text}")
295
+ print(f" {i:>2}. {name}{is_def}")
296
+ if desc:
297
+ print(f" {desc}")
298
+ print()
220
299
 
221
- print("\nCommands:")
222
- print(f" codefetch -l {owner}/<repo> List files in a repository")
223
- print(f" codefetch {owner}/<repo> <filename> Download a file from a repository")
224
- print(f" codefetch --set-default {owner}/<repo> Set default repository")
300
+ print("Commands:")
301
+ print(f" codefetch {owner}/<repo> <filename> Download a file")
302
+ print(f" codefetch -s {owner}/<repo> <filename> Preview file in terminal")
303
+ print(f" codefetch -l {owner}/<repo> List files in repository")
304
+ print(f" codefetch --set-default {owner}/<repo> Set as default repository")
225
305
 
226
306
  except urllib.error.HTTPError as e:
227
307
  print(f"Could not list repositories: HTTP {e.code}")
@@ -231,8 +311,8 @@ def list_repos(owner=None, default_repo=None):
231
311
  print(f"Could not list repositories: {e}")
232
312
 
233
313
 
234
- def list_files(owner, repo, branch="main"):
235
- """List code files in a repository."""
314
+ def list_files(owner, repo, branch=None):
315
+ """List code files in a repository, with automatic branch detection and fallback."""
236
316
  if not owner or not repo:
237
317
  print("Error: No repository specified to list.\n")
238
318
  print("Usage:")
@@ -240,8 +320,25 @@ def list_files(owner, repo, branch="main"):
240
320
  print(" codefetch -u <owner> -r <repo> -l")
241
321
  return
242
322
 
323
+ target_branch = branch
324
+ default_branch = get_default_branch(owner, repo)
325
+
326
+ # If branch was specified by user, verify it first
327
+ if target_branch:
328
+ if not check_branch_exists(owner, repo, target_branch):
329
+ print(f"Notice: Branch '{target_branch}' was not found in {owner}/{repo}.")
330
+ available_branches = get_repo_branches(owner, repo)
331
+ if available_branches:
332
+ shown = available_branches[:15]
333
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
334
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
335
+ print(f"Listing files from default branch '{default_branch}' instead:\n")
336
+ target_branch = default_branch
337
+ else:
338
+ target_branch = default_branch
339
+
243
340
  try:
244
- data = get_repo_tree(owner, repo, branch)
341
+ data = get_repo_tree(owner, repo, target_branch)
245
342
  if data.get("truncated"):
246
343
  print("Warning: GitHub returned a truncated file list.")
247
344
 
@@ -262,10 +359,10 @@ def list_files(owner, repo, branch="main"):
262
359
  files.sort(key=str.lower)
263
360
 
264
361
  if not files:
265
- print(f"No supported code files found in {owner}/{repo}.")
362
+ print(f"No supported code files found in {owner}/{repo} ({target_branch}).")
266
363
  return
267
364
 
268
- print(f"Available files in {owner}/{repo} ({branch}):\n")
365
+ print(f"Available files in {owner}/{repo} ({target_branch}):\n")
269
366
  for i, path in enumerate(files, 1):
270
367
  print(f"{i:>3}. {path}")
271
368
 
@@ -335,9 +432,9 @@ def find_file_in_other_repos(owner, current_repo, target_filename):
335
432
  r_name = r.get("name")
336
433
  if not r_name or (current_repo and r_name.lower() == current_repo.lower()):
337
434
  continue
338
- default_branch = r.get("default_branch", "main")
435
+ default_b = r.get("default_branch") or "main"
339
436
  try:
340
- tree_data = get_repo_tree(owner, r_name, default_branch)
437
+ tree_data = get_repo_tree(owner, r_name, default_b)
341
438
  match = resolve_file_in_tree(tree_data, target_filename)
342
439
  if match:
343
440
  return r_name, match
@@ -348,9 +445,14 @@ def find_file_in_other_repos(owner, current_repo, target_filename):
348
445
  return None, None
349
446
 
350
447
 
351
- def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
448
+ def fetch_file(filename, owner=None, repo=None, branch=None,
352
449
  show=False, output=None):
353
- """Fetch or download a file from GitHub."""
450
+ """
451
+ Fetch or download a file from GitHub.
452
+ - If branch is None: fetches the default branch of the repository.
453
+ - If branch is specified: tries that branch first; if not found, shows message,
454
+ displays all available branches, and falls back to default branch.
455
+ """
354
456
  if not owner or not repo:
355
457
  print("Error: No GitHub repository specified.\n")
356
458
  print("Usage:")
@@ -368,50 +470,159 @@ def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
368
470
  with urllib.request.urlopen(req, timeout=15) as resp:
369
471
  return resp.read().decode("utf-8")
370
472
 
371
- # 1. Direct raw fetch first (fastest, preserves GitHub API rate limit)
372
- raw_base = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}"
373
- url = f"{raw_base}/{clean_filename}"
374
-
375
473
  content = None
376
474
  resolved_path = clean_filename
475
+ actual_branch = None
377
476
 
378
- try:
379
- content = download_url(url)
380
- except urllib.error.HTTPError as e:
381
- if e.code == 404:
382
- # 2. Direct fetch 404ed. Try smart resolution with repo tree
477
+ default_branch = get_default_branch(owner, repo)
478
+
479
+ # Case A: Branch parameter was explicitly entered by the user
480
+ if branch:
481
+ # First, check if the specified branch exists
482
+ branch_exists = check_branch_exists(owner, repo, branch)
483
+
484
+ if not branch_exists:
485
+ print(f"Notice: Branch '{branch}' was not found in {owner}/{repo}.")
486
+ available_branches = get_repo_branches(owner, repo)
487
+ if available_branches:
488
+ shown = available_branches[:15]
489
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
490
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
491
+ print(f"Trying default branch '{default_branch}'...")
492
+
493
+ # Fall back to default branch
494
+ actual_branch = default_branch
495
+ try:
496
+ content = fetch_raw_single(owner, repo, default_branch, clean_filename)
497
+ except UnicodeDecodeError:
498
+ print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
499
+ return
500
+ except Exception:
501
+ pass
502
+
503
+ if content is None:
504
+ # Try tree on default branch
505
+ try:
506
+ tree_data = get_repo_tree(owner, repo, default_branch)
507
+ matched = resolve_file_in_tree(tree_data, clean_filename)
508
+ if matched:
509
+ resolved_path = matched
510
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
511
+ if resolved_path != clean_filename:
512
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
513
+ except Exception:
514
+ pass
515
+
516
+ if content is None:
517
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} (checked '{branch}' and default '{default_branch}').")
518
+ print(f"To fetch from a specific branch: codefetch -b <branch> {owner}/{repo} {clean_filename}")
519
+ return
520
+
521
+ else:
522
+ # The specified branch exists! First try direct fetch from it
523
+ actual_branch = branch
524
+ try:
525
+ content = fetch_raw_single(owner, repo, branch, clean_filename)
526
+ except UnicodeDecodeError:
527
+ print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
528
+ return
529
+ except Exception:
530
+ pass
531
+
532
+ if content is None:
533
+ # Direct raw 404ed, search tree of the specified branch
534
+ try:
535
+ tree_data = get_repo_tree(owner, repo, branch)
536
+ matched = resolve_file_in_tree(tree_data, clean_filename)
537
+ if matched:
538
+ resolved_path = matched
539
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{resolved_path}")
540
+ if resolved_path != clean_filename:
541
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
542
+ except Exception:
543
+ pass
544
+
545
+ # If file not in specified branch, check default branch as well
546
+ if content is None and branch.lower() != default_branch.lower():
547
+ print(f"Notice: File '{clean_filename}' not found on branch '{branch}'.")
548
+ available_branches = get_repo_branches(owner, repo)
549
+ if available_branches:
550
+ shown = available_branches[:15]
551
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
552
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
553
+ print(f"Trying default branch '{default_branch}'...")
554
+
555
+ try:
556
+ content = fetch_raw_single(owner, repo, default_branch, clean_filename)
557
+ if content is not None:
558
+ actual_branch = default_branch
559
+ except Exception:
560
+ pass
561
+
562
+ if content is None:
563
+ try:
564
+ tree_data = get_repo_tree(owner, repo, default_branch)
565
+ matched = resolve_file_in_tree(tree_data, clean_filename)
566
+ if matched:
567
+ resolved_path = matched
568
+ actual_branch = default_branch
569
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
570
+ if resolved_path != clean_filename:
571
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
572
+ except Exception:
573
+ pass
574
+
575
+ if content is None:
576
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({branch}).")
577
+ return
578
+
579
+ # Case B: No branch was specified (branch is None) -> fetch repo default branch
580
+ else:
581
+ actual_branch = default_branch
582
+ try:
583
+ content = fetch_raw_single(owner, repo, default_branch, clean_filename)
584
+ except UnicodeDecodeError:
585
+ print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
586
+ return
587
+ except urllib.error.HTTPError as e:
588
+ if e.code != 404:
589
+ print(f"Download failed: HTTP {e.code}")
590
+ return
591
+ except urllib.error.URLError:
592
+ print("Could not connect to GitHub.")
593
+ return
594
+
595
+ if content is None:
596
+ # Tree search on default branch
383
597
  try:
384
- tree_data = get_repo_tree(owner, repo, branch)
598
+ tree_data = get_repo_tree(owner, repo, default_branch)
385
599
  matched = resolve_file_in_tree(tree_data, clean_filename)
386
600
  if matched:
387
601
  resolved_path = matched
388
- content = download_url(f"{raw_base}/{resolved_path}")
602
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
389
603
  if resolved_path != clean_filename:
390
604
  print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
391
605
  else:
392
606
  # Check other public repositories for the same user
393
607
  other_repo, other_match = find_file_in_other_repos(owner, repo, clean_filename)
394
608
  if other_repo:
395
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
609
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
396
610
  print(f"\n[Tip] Found '{other_match}' in repository '{other_repo}'!")
397
611
  print(f"To fetch it, run:\n codefetch {owner}/{other_repo} {other_match}")
398
612
  return
399
613
  else:
400
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
614
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
401
615
  print(f"Run 'codefetch -l {owner}/{repo}' to see available files.")
402
616
  return
617
+ except urllib.error.HTTPError as e:
618
+ print(f"Could not search repository: HTTP {e.code}")
619
+ return
620
+ except urllib.error.URLError:
621
+ print("Could not connect to GitHub.")
622
+ return
403
623
  except Exception:
404
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
624
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
405
625
  return
406
- else:
407
- print(f"Download failed: HTTP {e.code}")
408
- return
409
- except urllib.error.URLError:
410
- print("Could not connect to GitHub.")
411
- return
412
- except UnicodeDecodeError:
413
- print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
414
- return
415
626
 
416
627
  if content is None:
417
628
  return
@@ -423,7 +634,7 @@ def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
423
634
  try:
424
635
  with open(out_name, "w", encoding="utf-8") as f:
425
636
  f.write(content)
426
- print(f"Downloaded: {out_name} (from {owner}/{repo})")
637
+ print(f"Downloaded: {out_name} (from {owner}/{repo}@{actual_branch})")
427
638
  except Exception as e:
428
639
  print(f"Error saving file '{out_name}': {e}")
429
640
 
@@ -440,87 +651,38 @@ class CodefetchParser(argparse.ArgumentParser):
440
651
 
441
652
 
442
653
  def build_parser():
443
- """Build argument parser with full CLI options."""
444
654
  parser = CodefetchParser(
445
655
  prog="codefetch",
446
- add_help=True,
447
- )
448
- parser.add_argument(
449
- "args",
450
- nargs="*",
451
- help="Target specification: <owner>/<repo> <filename> or <filename>",
452
- )
453
- parser.add_argument(
454
- "-r", "--repo",
455
- default=None,
456
- help="Repository name or URL (e.g. repo or owner/repo)",
457
- )
458
- parser.add_argument(
459
- "-u", "--user",
460
- default=None,
461
- help="GitHub user/org name",
462
- )
463
- parser.add_argument(
464
- "-b", "--branch",
465
- default=None,
466
- help=f"Branch name (default: {DEFAULT_BRANCH})",
467
- )
468
- parser.add_argument(
469
- "-l", "--list",
470
- action="store_true",
471
- help="List available code files in the repository",
472
- )
473
- parser.add_argument(
474
- "-R", "--repos",
475
- action="store_true",
476
- help="List all public repositories for a user",
477
- )
478
- parser.add_argument(
479
- "-s", "--show",
480
- action="store_true",
481
- help="Display file content in terminal without saving to disk",
482
- )
483
- parser.add_argument(
484
- "-o", "--output",
485
- default=None,
486
- help="Custom destination filename or output path",
487
- )
488
- parser.add_argument(
489
- "--set-default",
490
- metavar="REPO",
491
- help="Set persistent default repository in local config",
492
- )
493
- parser.add_argument(
494
- "--set-default-user",
495
- metavar="USER",
496
- help="Set persistent default GitHub user in local config",
497
- )
498
- parser.add_argument(
499
- "--config",
500
- action="store_true",
501
- help="Display current CodeFetch configuration",
502
- )
503
- parser.add_argument(
504
- "--reset-config",
505
- action="store_true",
506
- help="Reset configuration back to defaults",
507
- )
508
- parser.add_argument(
509
- "-v", "--version",
510
- action="version",
511
- version="codefetch 1.2.2",
656
+ description="Fetch code files from GitHub repositories.",
657
+ add_help=False,
512
658
  )
659
+
660
+ parser.add_argument("args", nargs="*", help="Repository and filename targets")
661
+ parser.add_argument("-s", "--show", action="store_true", help="Print file content to terminal")
662
+ parser.add_argument("-l", "--list", action="store_true", help="List code files in repository")
663
+ parser.add_argument("-R", "--repos", action="store_true", help="List public repositories for a user")
664
+ parser.add_argument("-r", "--repo", type=str, help="Repository name or URL")
665
+ parser.add_argument("-u", "--user", type=str, help="GitHub username")
666
+ parser.add_argument("-b", "--branch", type=str, default=None, help="Branch name (default: repository default branch)")
667
+ parser.add_argument("-o", "--output", type=str, help="Custom output filename or path")
668
+ parser.add_argument("--set-default", type=str, help="Save default repository (e.g. owner/repo)")
669
+ parser.add_argument("--set-default-user", type=str, help="Save default GitHub user")
670
+ parser.add_argument("--config", action="store_true", help="Display active configuration")
671
+ parser.add_argument("--reset-config", action="store_true", help="Reset configuration back to defaults")
672
+ parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
673
+ parser.add_argument("-h", "--help", action="help", help="Show help message")
674
+
513
675
  return parser
514
676
 
515
677
 
516
678
  def resolve_invocation(positional_args, repo_flag, user_flag, branch_flag,
517
- default_owner, default_repo, default_branch):
679
+ default_owner, default_repo):
518
680
  """
519
681
  Resolve arguments into (owner, repo, branch, filename).
520
682
  """
521
683
  owner = user_flag or default_owner
522
684
  repo = default_repo
523
- branch = branch_flag or default_branch
685
+ branch = branch_flag # None if not provided
524
686
  filename = None
525
687
 
526
688
  if repo_flag:
@@ -579,7 +741,6 @@ def main():
579
741
  config = load_config()
580
742
  default_owner = config.get("owner", DEFAULT_OWNER)
581
743
  default_repo = config.get("repo", DEFAULT_REPO)
582
- default_branch = config.get("branch", DEFAULT_BRANCH)
583
744
 
584
745
  # Handle --reset-config
585
746
  if args.reset_config:
@@ -615,12 +776,12 @@ def main():
615
776
  print("CodeFetch Configuration:")
616
777
  print(f" Default User: {default_owner or 'None (not set)'}")
617
778
  print(f" Default Repository: {default_repo or 'None (not set)'}")
618
- print(f" Default Branch: {default_branch}")
779
+ print(" Default Branch: None (auto-detect repository default)")
619
780
  print(f" Config File: {CONFIG_FILE}")
620
781
  return
621
782
 
622
783
  owner = args.user or default_owner
623
- branch = args.branch or default_branch
784
+ branch = args.branch # None if not provided
624
785
 
625
786
  # Handle --repos (list repositories)
626
787
  if args.repos:
@@ -665,7 +826,6 @@ def main():
665
826
  args.branch,
666
827
  default_owner,
667
828
  default_repo,
668
- default_branch,
669
829
  )
670
830
 
671
831
  if not resolved_filename:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codefetch-cli
3
- Version: 1.2.2
3
+ Version: 1.2.4
4
4
  Summary: Quickly fetch code files from GitHub repositories with simple commands
5
5
  Home-page: https://github.com/InzamamulQureshi/Codefetch
6
6
  Author: Inzamamul Qureshi
@@ -24,7 +24,7 @@ Dynamic: requires-python
24
24
 
25
25
  Quickly fetch code files from GitHub repositories with simple commands.
26
26
 
27
- CodeFetch is a lightweight command-line utility designed to quickly inspect, preview, and download individual source code files directly from any GitHub repository without needing to clone full repositories.
27
+ CodeFetch is a lightweight command-line utility and Python library designed to quickly inspect, preview, and download individual source code files directly from any GitHub repository without needing to clone full repositories.
28
28
 
29
29
  ---
30
30
 
@@ -35,6 +35,7 @@ CodeFetch is a lightweight command-line utility designed to quickly inspect, pre
35
35
  - **Automatic Path & Case Resolution**: Automatically resolves case mismatches and finds files nested inside subdirectories.
36
36
  - **Repository Discovery**: List all public repositories for any GitHub user with `--repos`.
37
37
  - **Tree Exploration**: List all supported code files within a repository with `--list`.
38
+ - **Python Library Support**: Import and use programmatically with `import codefetch as cf`.
38
39
  - **Optional Persistent Defaults**: Save a default repository or user with `--set-default` if you frequently work with the same repository.
39
40
  - **Zero Runtime Dependencies**: Built using only the Python standard library with zero third-party dependencies.
40
41
 
@@ -130,6 +131,28 @@ codefetch --reset-config
130
131
 
131
132
  ---
132
133
 
134
+ ## Python API Usage
135
+
136
+ You can also use CodeFetch programmatically within your Python scripts:
137
+
138
+ ```python
139
+ import codefetch as cf
140
+
141
+ # 1. Fetch file content as a string directly in memory
142
+ makefile_text = cf.get("torvalds/linux", "Makefile")
143
+ # Or shorthand single argument syntax:
144
+ readme_text = cf.get("psf/requests/README.md")
145
+
146
+ # 2. Download a file to disk
147
+ cf.download("torvalds/linux", "Makefile", output="linux_Makefile")
148
+
149
+ # 3. List all code files in a repository
150
+ files = cf.list("torvalds/linux")
151
+ print(f"Found {len(files)} files")
152
+ ```
153
+
154
+ ---
155
+
133
156
  ## Command Reference
134
157
 
135
158
  | Flag | Description | Example |
@@ -0,0 +1,8 @@
1
+ codefetch/__init__.py,sha256=3VbBrolY7csLzdP8QSlksD1iBNJqaEa2CvyZhg_sdYk,5049
2
+ codefetch/cli.py,sha256=uun_VFjFI_B4PPFzKJhPU8IcTrsQY0OuU6nzAYRXIvQ,32190
3
+ codefetch_cli-1.2.4.dist-info/licenses/LICENSE,sha256=hqEPPm8jxPava5BfKIj3wTIXNyWNwsIvPv2daPCOijY,1094
4
+ codefetch_cli-1.2.4.dist-info/METADATA,sha256=a6mkv8w6CLgKRlQTXFb-UAEfdLCJCjhrPpjAtu041Dk,6675
5
+ codefetch_cli-1.2.4.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ codefetch_cli-1.2.4.dist-info/entry_points.txt,sha256=jFLRsBB_USeSdqjewcXZfnOzY56s0PZat7mnYkYdrXg,49
7
+ codefetch_cli-1.2.4.dist-info/top_level.txt,sha256=kHrftHbyqugb8LQXjW1dR9uI-JFjZQJ0sUReIa45yXQ,10
8
+ codefetch_cli-1.2.4.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- codefetch/__init__.py,sha256=OgTOFFfQIh-gT-X8j2hheukYFmfAe0SvzIuA_LosY6Y,98
2
- codefetch/cli.py,sha256=muL99Mr9tCPZ0UJp3YfVcVGMxTLvCPGfEbOmH-GpNCU,23588
3
- codefetch_cli-1.2.2.dist-info/licenses/LICENSE,sha256=hqEPPm8jxPava5BfKIj3wTIXNyWNwsIvPv2daPCOijY,1094
4
- codefetch_cli-1.2.2.dist-info/METADATA,sha256=pOL04v-tCvJap5vLYlCE9bDXBv768lm-LR5gghvyzlc,6000
5
- codefetch_cli-1.2.2.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
- codefetch_cli-1.2.2.dist-info/entry_points.txt,sha256=jFLRsBB_USeSdqjewcXZfnOzY56s0PZat7mnYkYdrXg,49
7
- codefetch_cli-1.2.2.dist-info/top_level.txt,sha256=kHrftHbyqugb8LQXjW1dR9uI-JFjZQJ0sUReIa45yXQ,10
8
- codefetch_cli-1.2.2.dist-info/RECORD,,