codefetch-cli 1.2.3__py3-none-any.whl → 1.2.5__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
@@ -16,17 +16,17 @@ Usage in Python:
16
16
  files = cf.list("torvalds/linux")
17
17
  """
18
18
 
19
- __version__ = "1.2.3"
19
+ __version__ = "1.2.5"
20
20
 
21
21
 
22
- def get(target, filename=None, branch="main"):
22
+ def get(target, filename=None, branch=None):
23
23
  """
24
24
  Fetch and return the text content of a file from GitHub.
25
25
 
26
26
  Args:
27
27
  target: 'owner/repo' or full path 'owner/repo/path/to/file' or full GitHub URL.
28
28
  filename: Optional path to file in repo if target is 'owner/repo'.
29
- branch: Branch name (default: 'main', automatically falls back to 'master').
29
+ branch: Branch name (default: None, automatically detects repo default branch).
30
30
 
31
31
  Returns:
32
32
  str: File content.
@@ -37,8 +37,10 @@ def get(target, filename=None, branch="main"):
37
37
  """
38
38
  import urllib.request
39
39
  from .cli import (
40
- fetch_raw_direct,
41
- get_repo_tree_with_branch,
40
+ get_default_branch,
41
+ check_branch_exists,
42
+ fetch_raw_single,
43
+ get_repo_tree,
42
44
  resolve_file_in_tree,
43
45
  parse_repo_identifier,
44
46
  resolve_invocation,
@@ -49,7 +51,7 @@ def get(target, filename=None, branch="main"):
49
51
  file_path = filename.strip("/\\")
50
52
  else:
51
53
  owner, repo, resolved_branch, file_path = resolve_invocation(
52
- [target], None, None, branch, None, None, "main"
54
+ [target], None, None, branch, None, None
53
55
  )
54
56
  if resolved_branch:
55
57
  branch = resolved_branch
@@ -57,24 +59,30 @@ def get(target, filename=None, branch="main"):
57
59
  if not owner or not repo or not file_path:
58
60
  raise ValueError(f"Could not resolve owner, repo, and filename from target='{target}', filename='{filename}'")
59
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
+
60
68
  # 1. Try fast direct raw fetch
61
- found_branch, content = fetch_raw_direct(owner, repo, branch, file_path)
69
+ content = fetch_raw_single(owner, repo, target_branch, file_path)
62
70
  if content is not None:
63
71
  return content
64
72
 
65
73
  # 2. Try repository tree search
66
- actual_branch, tree_data = get_repo_tree_with_branch(owner, repo, branch)
74
+ tree_data = get_repo_tree(owner, repo, target_branch)
67
75
  matched = resolve_file_in_tree(tree_data, file_path)
68
76
  if matched:
69
- url = f"https://raw.githubusercontent.com/{owner}/{repo}/{actual_branch}/{matched}"
77
+ url = f"https://raw.githubusercontent.com/{owner}/{repo}/{target_branch}/{matched}"
70
78
  req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
71
79
  with urllib.request.urlopen(req, timeout=15) as resp:
72
80
  return resp.read().decode("utf-8")
73
81
 
74
- raise FileNotFoundError(f"File '{file_path}' not found in {owner}/{repo} ({actual_branch})")
82
+ raise FileNotFoundError(f"File '{file_path}' not found in {owner}/{repo} ({target_branch})")
75
83
 
76
84
 
77
- def download(target, filename=None, output=None, branch="main"):
85
+ def download(target, filename=None, output=None, branch=None):
78
86
  """
79
87
  Download a file from GitHub and save it locally.
80
88
 
@@ -82,7 +90,7 @@ def download(target, filename=None, output=None, branch="main"):
82
90
  target: 'owner/repo' or full path 'owner/repo/path/to/file'.
83
91
  filename: Optional path to file in repo if target is 'owner/repo'.
84
92
  output: Destination filename/path on local disk (default: original filename).
85
- branch: Branch name (default: 'main').
93
+ branch: Branch name (default: None, automatically detects repo default branch).
86
94
 
87
95
  Returns:
88
96
  str: Path to the downloaded file.
@@ -99,20 +107,22 @@ def download(target, filename=None, output=None, branch="main"):
99
107
  return dest_name
100
108
 
101
109
 
102
- def list(target, branch="main"):
110
+ def list(target, branch=None):
103
111
  """
104
112
  List supported code files in a repository.
105
113
 
106
114
  Args:
107
115
  target: 'owner/repo' or repository URL.
108
- branch: Branch name (default: 'main').
116
+ branch: Branch name (default: None, automatically detects repo default branch).
109
117
 
110
118
  Returns:
111
119
  list[str]: Relative paths of code files in the repository.
112
120
  """
113
121
  from .cli import (
114
122
  parse_repo_identifier,
115
- get_repo_tree_with_branch,
123
+ get_default_branch,
124
+ check_branch_exists,
125
+ get_repo_tree,
116
126
  CODE_EXTENSIONS,
117
127
  NAMED_FILES,
118
128
  )
@@ -121,7 +131,11 @@ def list(target, branch="main"):
121
131
  if not owner or not repo:
122
132
  raise ValueError(f"Invalid repository identifier: '{target}'")
123
133
 
124
- actual_branch, tree_data = get_repo_tree_with_branch(owner, repo, branch)
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)
125
139
  files = []
126
140
  for item in tree_data.get("tree", []):
127
141
  if item.get("type") != "blob":
codefetch/cli.py CHANGED
@@ -7,6 +7,7 @@ import argparse
7
7
  import json
8
8
  import os
9
9
  import re
10
+ import subprocess
10
11
  import sys
11
12
  import urllib.error
12
13
  import urllib.request
@@ -14,11 +15,10 @@ from pathlib import Path
14
15
 
15
16
  DEFAULT_OWNER = None
16
17
  DEFAULT_REPO = None
17
- DEFAULT_BRANCH = "main"
18
18
 
19
19
  CONFIG_FILE = Path.home() / ".codefetch.json"
20
20
 
21
- __version__ = "1.2.3"
21
+ __version__ = "1.2.5"
22
22
 
23
23
  # Supported code, data, and config extensions
24
24
  CODE_EXTENSIONS = {
@@ -62,6 +62,7 @@ USAGE:
62
62
  codefetch -s <owner>/<repo> <filename> Preview file in terminal without saving
63
63
  codefetch -l <owner>/<repo> List all code files in a repository
64
64
  codefetch -R <username> List public repositories for a user
65
+ codefetch --update Update CodeFetch to the latest version
65
66
 
66
67
  POPULAR EXAMPLES:
67
68
  # Download from any repository:
@@ -93,36 +94,42 @@ FLAGS & OPTIONS:
93
94
  -R, --repos List public repositories for a GitHub user
94
95
  -r, --repo <repo> Specify repository name or URL
95
96
  -u, --user <user> Specify GitHub username / organization
96
- -b, --branch <branch> Specify branch name (default: main)
97
+ -b, --branch <branch> Specify branch name (default: repository default branch)
97
98
  --set-default <repo> Save default repository (e.g. owner/repo)
98
99
  --set-default-user <user> Save default user/organization
99
100
  --config Show current saved configuration
100
101
  --reset-config Reset configuration back to defaults
102
+ -U, --update Update CodeFetch to the latest version from PyPI
101
103
  -v, --version Show version number
102
104
  -h, --help Show this help message
103
105
  """
104
106
 
107
+ _REPO_DEFAULT_BRANCH_CACHE = {}
108
+ _REPO_BRANCHES_CACHE = {}
109
+
105
110
 
106
111
  def load_config():
107
112
  """Load user configuration from ~/.codefetch.json."""
108
113
  if CONFIG_FILE.exists():
109
114
  try:
110
115
  with open(CONFIG_FILE, "r", encoding="utf-8") as f:
111
- return json.load(f)
116
+ data = json.load(f)
117
+ return {
118
+ "owner": data.get("owner"),
119
+ "repo": data.get("repo"),
120
+ }
112
121
  except Exception:
113
122
  return {}
114
123
  return {}
115
124
 
116
125
 
117
- def save_config(owner=None, repo=None, branch=None):
126
+ def save_config(owner=None, repo=None):
118
127
  """Save user configuration to ~/.codefetch.json."""
119
128
  cfg = load_config()
120
129
  if owner is not None:
121
130
  cfg["owner"] = owner
122
131
  if repo is not None:
123
132
  cfg["repo"] = repo
124
- if branch is not None:
125
- cfg["branch"] = branch
126
133
 
127
134
  try:
128
135
  with open(CONFIG_FILE, "w", encoding="utf-8") as f:
@@ -168,76 +175,99 @@ def parse_repo_identifier(repo_str, default_owner=None):
168
175
  return default_owner, repo_str.removesuffix(".git")
169
176
 
170
177
 
171
- def fetch_raw_direct(owner, repo, branch, clean_filename):
178
+ def get_default_branch(owner, repo):
172
179
  """
173
- Direct raw fetch from raw.githubusercontent.com.
174
- Tries requested branch first. If branch is 'main', also tries 'master',
175
- and vice versa.
176
- Returns (branch_used, content_str) or (None, None).
180
+ Get the default branch of a repository (e.g. 'main', 'master', 'unstable').
181
+ Queries GitHub API and caches the result. Falls back to probing if rate limited.
177
182
  """
178
- branches_to_try = [branch]
179
- if branch == "main":
180
- branches_to_try.append("master")
181
- elif branch == "master":
182
- branches_to_try.append("main")
183
-
184
- for b in branches_to_try:
185
- url = f"https://raw.githubusercontent.com/{owner}/{repo}/{b}/{clean_filename}"
186
- try:
187
- req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
188
- with urllib.request.urlopen(req, timeout=15) as resp:
189
- return b, resp.read().decode("utf-8")
190
- except urllib.error.HTTPError as e:
191
- if e.code == 404:
192
- continue
193
- raise
194
- return None, None
183
+ key = f"{owner.lower()}/{repo.lower()}"
184
+ if key in _REPO_DEFAULT_BRANCH_CACHE:
185
+ return _REPO_DEFAULT_BRANCH_CACHE[key]
195
186
 
187
+ try:
188
+ url = f"https://api.github.com/repos/{owner}/{repo}"
189
+ data = get_json(url)
190
+ def_b = data.get("default_branch")
191
+ if def_b:
192
+ _REPO_DEFAULT_BRANCH_CACHE[key] = def_b
193
+ return def_b
194
+ except Exception:
195
+ pass
196
196
 
197
- def get_repo_tree_with_branch(owner, repo, branch="main"):
198
- """
199
- Fetch the Git tree for a repository, returning (actual_branch, tree_data).
200
- Tries branch first, then fallback to master/main, then checks repository info for default_branch.
201
- """
202
- branches_to_try = [branch]
203
- if branch == "main":
204
- branches_to_try.append("master")
205
- elif branch == "master":
206
- branches_to_try.append("main")
207
-
208
- for b in branches_to_try:
209
- url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{b}?recursive=1"
197
+ # Fast fallback probe via CDN if API is unavailable or rate-limited
198
+ for b in ("main", "master"):
210
199
  try:
211
- return b, get_json(url)
212
- except urllib.error.HTTPError as e:
213
- if e.code == 404:
214
- continue
215
- raise
200
+ req = urllib.request.Request(
201
+ f"https://raw.githubusercontent.com/{owner}/{repo}/{b}/README.md",
202
+ headers=get_headers(),
203
+ )
204
+ with urllib.request.urlopen(req, timeout=5):
205
+ _REPO_DEFAULT_BRANCH_CACHE[key] = b
206
+ return b
207
+ except Exception:
208
+ continue
209
+
210
+ return "main"
211
+
212
+
213
+ def get_repo_branches(owner, repo):
214
+ """Fetch the list of all branch names in a repository."""
215
+ key = f"{owner.lower()}/{repo.lower()}"
216
+ if key in _REPO_BRANCHES_CACHE:
217
+ return _REPO_BRANCHES_CACHE[key]
216
218
 
217
- # Query repository info to discover actual default branch (e.g. develop, trunk, etc.)
218
219
  try:
219
- info_url = f"https://api.github.com/repos/{owner}/{repo}"
220
- info = get_json(info_url)
221
- def_branch = info.get("default_branch")
222
- if def_branch and def_branch not in branches_to_try:
223
- url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{def_branch}?recursive=1"
224
- return def_branch, get_json(url)
220
+ url = f"https://api.github.com/repos/{owner}/{repo}/branches?per_page=100"
221
+ data = get_json(url)
222
+ branches = [b["name"] for b in data if isinstance(b, dict) and b.get("name")]
223
+ if branches:
224
+ _REPO_BRANCHES_CACHE[key] = branches
225
+ return branches
225
226
  except Exception:
226
227
  pass
228
+ return []
227
229
 
228
- raise urllib.error.HTTPError(
229
- f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}",
230
- 404,
231
- "Branch tree not found",
232
- {},
233
- None,
234
- )
230
+
231
+ def check_branch_exists(owner, repo, branch):
232
+ """Check if a specific branch exists in a repository."""
233
+ if not branch:
234
+ return False
235
+ branches = get_repo_branches(owner, repo)
236
+ if branches:
237
+ return branch in branches
238
+ try:
239
+ get_json(f"https://api.github.com/repos/{owner}/{repo}/branches/{branch}")
240
+ return True
241
+ except urllib.error.HTTPError as e:
242
+ if e.code == 404:
243
+ return False
244
+ return True
245
+ except Exception:
246
+ return True
247
+
248
+
249
+ def fetch_raw_single(owner, repo, branch, clean_filename):
250
+ """
251
+ Direct raw fetch from raw.githubusercontent.com for a specific branch.
252
+ Returns content string or None if 404.
253
+ """
254
+ url = f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{clean_filename}"
255
+ try:
256
+ req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
257
+ with urllib.request.urlopen(req, timeout=15) as resp:
258
+ return resp.read().decode("utf-8")
259
+ except urllib.error.HTTPError as e:
260
+ if e.code == 404:
261
+ return None
262
+ raise
263
+ except UnicodeDecodeError:
264
+ raise
235
265
 
236
266
 
237
- def get_repo_tree(owner, repo, branch="main"):
238
- """Fetch the Git tree for a repository (backwards compatible helper)."""
239
- _, tree_data = get_repo_tree_with_branch(owner, repo, branch)
240
- return tree_data
267
+ def get_repo_tree(owner, repo, branch):
268
+ """Fetch the Git tree for a repository on a given branch."""
269
+ url = f"https://api.github.com/repos/{owner}/{repo}/git/trees/{branch}?recursive=1"
270
+ return get_json(url)
241
271
 
242
272
 
243
273
  def get_user_repos(owner):
@@ -247,7 +277,7 @@ def get_user_repos(owner):
247
277
 
248
278
 
249
279
  def list_repos(owner=None, default_repo=None):
250
- """List all public repositories for a GitHub user."""
280
+ """List all public repositories for a GitHub user with clean spacing."""
251
281
  if not owner:
252
282
  print("Error: No GitHub username specified.")
253
283
  print("Usage: codefetch --repos <username>")
@@ -259,21 +289,21 @@ def list_repos(owner=None, default_repo=None):
259
289
  print(f"No public repositories found for user '{owner}'.")
260
290
  return
261
291
 
262
- print(f"Public repositories for '{owner}':\n")
292
+ print(f"Public repositories for '{owner}' ({len(repos)} total):\n")
263
293
  for i, r in enumerate(repos, 1):
264
294
  name = r.get("name", "")
265
- desc = r.get("description") or "No description"
295
+ desc = (r.get("description") or "").strip()
266
296
  is_def = " (default)" if default_repo and name.lower() == default_repo.lower() else ""
267
- marker = "*" if is_def else "-"
268
- print(f" {marker} {name:<22}{is_def}")
269
- if desc and desc != "No description":
297
+ print(f" {i:>2}. {name}{is_def}")
298
+ if desc:
270
299
  print(f" {desc}")
300
+ print()
271
301
 
272
- print(f"\nTotal: {len(repos)} repositories")
273
- print("\nCommands:")
274
- print(f" codefetch -l {owner}/<repo> List files in a repository")
275
- print(f" codefetch {owner}/<repo> <filename> Download a file from a repository")
276
- print(f" codefetch --set-default {owner}/<repo> Set default repository")
302
+ print("Commands:")
303
+ print(f" codefetch {owner}/<repo> <filename> Download a file")
304
+ print(f" codefetch -s {owner}/<repo> <filename> Preview file in terminal")
305
+ print(f" codefetch -l {owner}/<repo> List files in repository")
306
+ print(f" codefetch --set-default {owner}/<repo> Set as default repository")
277
307
 
278
308
  except urllib.error.HTTPError as e:
279
309
  print(f"Could not list repositories: HTTP {e.code}")
@@ -283,8 +313,8 @@ def list_repos(owner=None, default_repo=None):
283
313
  print(f"Could not list repositories: {e}")
284
314
 
285
315
 
286
- def list_files(owner, repo, branch="main"):
287
- """List code files in a repository."""
316
+ def list_files(owner, repo, branch=None):
317
+ """List code files in a repository, with automatic branch detection and fallback."""
288
318
  if not owner or not repo:
289
319
  print("Error: No repository specified to list.\n")
290
320
  print("Usage:")
@@ -292,8 +322,25 @@ def list_files(owner, repo, branch="main"):
292
322
  print(" codefetch -u <owner> -r <repo> -l")
293
323
  return
294
324
 
325
+ target_branch = branch
326
+ default_branch = get_default_branch(owner, repo)
327
+
328
+ # If branch was specified by user, verify it first
329
+ if target_branch:
330
+ if not check_branch_exists(owner, repo, target_branch):
331
+ print(f"Notice: Branch '{target_branch}' was not found in {owner}/{repo}.")
332
+ available_branches = get_repo_branches(owner, repo)
333
+ if available_branches:
334
+ shown = available_branches[:15]
335
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
336
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
337
+ print(f"Listing files from default branch '{default_branch}' instead:\n")
338
+ target_branch = default_branch
339
+ else:
340
+ target_branch = default_branch
341
+
295
342
  try:
296
- actual_branch, data = get_repo_tree_with_branch(owner, repo, branch)
343
+ data = get_repo_tree(owner, repo, target_branch)
297
344
  if data.get("truncated"):
298
345
  print("Warning: GitHub returned a truncated file list.")
299
346
 
@@ -314,10 +361,10 @@ def list_files(owner, repo, branch="main"):
314
361
  files.sort(key=str.lower)
315
362
 
316
363
  if not files:
317
- print(f"No supported code files found in {owner}/{repo} ({actual_branch}).")
364
+ print(f"No supported code files found in {owner}/{repo} ({target_branch}).")
318
365
  return
319
366
 
320
- print(f"Available files in {owner}/{repo} ({actual_branch}):\n")
367
+ print(f"Available files in {owner}/{repo} ({target_branch}):\n")
321
368
  for i, path in enumerate(files, 1):
322
369
  print(f"{i:>3}. {path}")
323
370
 
@@ -387,9 +434,9 @@ def find_file_in_other_repos(owner, current_repo, target_filename):
387
434
  r_name = r.get("name")
388
435
  if not r_name or (current_repo and r_name.lower() == current_repo.lower()):
389
436
  continue
390
- default_branch = r.get("default_branch", "main")
437
+ default_b = r.get("default_branch") or "main"
391
438
  try:
392
- tree_data = get_repo_tree(owner, r_name, default_branch)
439
+ tree_data = get_repo_tree(owner, r_name, default_b)
393
440
  match = resolve_file_in_tree(tree_data, target_filename)
394
441
  if match:
395
442
  return r_name, match
@@ -400,9 +447,14 @@ def find_file_in_other_repos(owner, current_repo, target_filename):
400
447
  return None, None
401
448
 
402
449
 
403
- def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
450
+ def fetch_file(filename, owner=None, repo=None, branch=None,
404
451
  show=False, output=None):
405
- """Fetch or download a file from GitHub."""
452
+ """
453
+ Fetch or download a file from GitHub.
454
+ - If branch is None: fetches the default branch of the repository.
455
+ - If branch is specified: tries that branch first; if not found, shows message,
456
+ displays all available branches, and falls back to default branch.
457
+ """
406
458
  if not owner or not repo:
407
459
  print("Error: No GitHub repository specified.\n")
408
460
  print("Usage:")
@@ -422,63 +474,150 @@ def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
422
474
 
423
475
  content = None
424
476
  resolved_path = clean_filename
425
- actual_branch = branch
477
+ actual_branch = None
426
478
 
427
- # 1. Direct raw fetch first (fastest, preserves GitHub API rate limit)
428
- try:
429
- found_branch, raw_content = fetch_raw_direct(owner, repo, branch, clean_filename)
430
- if raw_content is not None:
431
- actual_branch = found_branch
432
- content = raw_content
433
- except UnicodeDecodeError:
434
- print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
435
- return
436
- except urllib.error.HTTPError as e:
437
- print(f"Download failed: HTTP {e.code}")
438
- return
439
- except urllib.error.URLError:
440
- print("Could not connect to GitHub.")
441
- return
479
+ default_branch = get_default_branch(owner, repo)
442
480
 
443
- # 2. If direct fetch didn't find the file (404), fall back to smart repository tree search
444
- if content is None:
481
+ # Case A: Branch parameter was explicitly entered by the user
482
+ if branch:
483
+ branch_exists = check_branch_exists(owner, repo, branch)
484
+
485
+ if not branch_exists:
486
+ print(f"Notice: Branch '{branch}' was not found in {owner}/{repo}.")
487
+ available_branches = get_repo_branches(owner, repo)
488
+ if available_branches:
489
+ shown = available_branches[:15]
490
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
491
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
492
+ print(f"Trying default branch '{default_branch}'...")
493
+
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:
505
+ tree_data = get_repo_tree(owner, repo, default_branch)
506
+ matched = resolve_file_in_tree(tree_data, clean_filename)
507
+ if matched:
508
+ resolved_path = matched
509
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
510
+ if resolved_path != clean_filename:
511
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
512
+ except Exception:
513
+ pass
514
+
515
+ if content is None:
516
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} (checked '{branch}' and default '{default_branch}').")
517
+ print(f"To fetch from a specific branch: codefetch -b <branch> {owner}/{repo} {clean_filename}")
518
+ return
519
+
520
+ else:
521
+ actual_branch = branch
522
+ try:
523
+ content = fetch_raw_single(owner, repo, branch, clean_filename)
524
+ except UnicodeDecodeError:
525
+ print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
526
+ return
527
+ except Exception:
528
+ pass
529
+
530
+ if content is None:
531
+ try:
532
+ tree_data = get_repo_tree(owner, repo, branch)
533
+ matched = resolve_file_in_tree(tree_data, clean_filename)
534
+ if matched:
535
+ resolved_path = matched
536
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{resolved_path}")
537
+ if resolved_path != clean_filename:
538
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
539
+ except Exception:
540
+ pass
541
+
542
+ if content is None and branch.lower() != default_branch.lower():
543
+ print(f"Notice: File '{clean_filename}' not found on branch '{branch}'.")
544
+ available_branches = get_repo_branches(owner, repo)
545
+ if available_branches:
546
+ shown = available_branches[:15]
547
+ extra = f" (+{len(available_branches) - 15} more)" if len(available_branches) > 15 else ""
548
+ print(f"Available branches in {owner}/{repo}: {', '.join(shown)}{extra}")
549
+ print(f"Trying default branch '{default_branch}'...")
550
+
551
+ try:
552
+ content = fetch_raw_single(owner, repo, default_branch, clean_filename)
553
+ if content is not None:
554
+ actual_branch = default_branch
555
+ except Exception:
556
+ pass
557
+
558
+ if content is None:
559
+ try:
560
+ tree_data = get_repo_tree(owner, repo, default_branch)
561
+ matched = resolve_file_in_tree(tree_data, clean_filename)
562
+ if matched:
563
+ resolved_path = matched
564
+ actual_branch = default_branch
565
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
566
+ if resolved_path != clean_filename:
567
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
568
+ except Exception:
569
+ pass
570
+
571
+ if content is None:
572
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({branch}).")
573
+ return
574
+
575
+ # Case B: No branch was specified (branch is None) -> fetch repo default branch
576
+ else:
577
+ actual_branch = default_branch
445
578
  try:
446
- actual_branch, tree_data = get_repo_tree_with_branch(owner, repo, branch)
447
- matched = resolve_file_in_tree(tree_data, clean_filename)
448
- if matched:
449
- resolved_path = matched
450
- raw_url = f"https://raw.githubusercontent.com/{owner}/{repo}/{actual_branch}/{resolved_path}"
451
- content = download_url(raw_url)
452
- if resolved_path != clean_filename:
453
- print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
454
- else:
455
- # Check other public repositories for the same user
456
- other_repo, other_match = find_file_in_other_repos(owner, repo, clean_filename)
457
- if other_repo:
458
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
459
- print(f"\n[Tip] Found '{other_match}' in repository '{other_repo}'!")
460
- print(f"To fetch it, run:\n codefetch {owner}/{other_repo} {other_match}")
461
- return
462
- else:
463
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
464
- print(f"Run 'codefetch -l {owner}/{repo}' to see available files.")
465
- return
466
- except urllib.error.HTTPError as e:
467
- if e.code == 404:
468
- print(f"Repository or branch not found: {owner}/{repo} ({branch})")
469
- else:
470
- print(f"Could not search repository: HTTP {e.code}")
471
- return
472
- except urllib.error.URLError:
473
- print("Could not connect to GitHub.")
474
- return
579
+ content = fetch_raw_single(owner, repo, default_branch, clean_filename)
475
580
  except UnicodeDecodeError:
476
581
  print("The file is not UTF-8 text, so it cannot be displayed or saved as text.")
477
582
  return
478
- except Exception:
479
- print(f"File not found: '{clean_filename}' in {owner}/{repo}.")
583
+ except urllib.error.HTTPError as e:
584
+ if e.code != 404:
585
+ print(f"Download failed: HTTP {e.code}")
586
+ return
587
+ except urllib.error.URLError:
588
+ print("Could not connect to GitHub.")
480
589
  return
481
590
 
591
+ if content is None:
592
+ try:
593
+ tree_data = get_repo_tree(owner, repo, default_branch)
594
+ matched = resolve_file_in_tree(tree_data, clean_filename)
595
+ if matched:
596
+ resolved_path = matched
597
+ content = download_url(f"https://raw.githubusercontent.com/{owner}/{repo}/{default_branch}/{resolved_path}")
598
+ if resolved_path != clean_filename:
599
+ print(f"(Resolved '{clean_filename}' -> '{resolved_path}')")
600
+ else:
601
+ other_repo, other_match = find_file_in_other_repos(owner, repo, clean_filename)
602
+ if other_repo:
603
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
604
+ print(f"\n[Tip] Found '{other_match}' in repository '{other_repo}'!")
605
+ print(f"To fetch it, run:\n codefetch {owner}/{other_repo} {other_match}")
606
+ return
607
+ else:
608
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
609
+ print(f"Run 'codefetch -l {owner}/{repo}' to see available files.")
610
+ return
611
+ except urllib.error.HTTPError as e:
612
+ print(f"Could not search repository: HTTP {e.code}")
613
+ return
614
+ except urllib.error.URLError:
615
+ print("Could not connect to GitHub.")
616
+ return
617
+ except Exception:
618
+ print(f"File not found: '{clean_filename}' in {owner}/{repo} ({default_branch}).")
619
+ return
620
+
482
621
  if content is None:
483
622
  return
484
623
 
@@ -494,6 +633,56 @@ def fetch_file(filename, owner=None, repo=None, branch=DEFAULT_BRANCH,
494
633
  print(f"Error saving file '{out_name}': {e}")
495
634
 
496
635
 
636
+ def parse_version_tuple(v_str):
637
+ """Parse version string like '1.2.5' into a comparable tuple of integers."""
638
+ try:
639
+ return tuple(int(x) for x in re.findall(r"\d+", v_str))
640
+ except Exception:
641
+ return (0,)
642
+
643
+
644
+ def update_codefetch():
645
+ """Update codefetch-cli to the latest version from PyPI."""
646
+ print("Checking for updates from PyPI...")
647
+ package_name = "codefetch-cli"
648
+ latest_version = None
649
+
650
+ try:
651
+ url = f"https://pypi.org/pypi/{package_name}/json"
652
+ req = urllib.request.Request(url, headers={"User-Agent": "codefetch"})
653
+ with urllib.request.urlopen(req, timeout=10) as resp:
654
+ data = json.loads(resp.read().decode("utf-8"))
655
+ latest_version = data.get("info", {}).get("version")
656
+ except Exception as e:
657
+ print(f"Note: Could not check PyPI metadata ({e}).")
658
+
659
+ if latest_version:
660
+ curr_tuple = parse_version_tuple(__version__)
661
+ latest_tuple = parse_version_tuple(latest_version)
662
+
663
+ if latest_tuple <= curr_tuple:
664
+ print(f"CodeFetch is already up to date (version {__version__}).")
665
+ return
666
+
667
+ print(f"Update available: v{latest_version} (current: v{__version__})")
668
+ print(f"Updating {package_name}...")
669
+ else:
670
+ print(f"Updating {package_name} to latest version...")
671
+
672
+ cmd = [sys.executable, "-m", "pip", "install", "--upgrade", package_name]
673
+ try:
674
+ result = subprocess.run(cmd)
675
+ if result.returncode == 0:
676
+ target_v = f"v{latest_version}" if latest_version else "the latest version"
677
+ print(f"\nSuccessfully updated CodeFetch to {target_v}!")
678
+ else:
679
+ print(f"\nUpdate failed (exit code {result.returncode}).")
680
+ print(f"You can manually update with: pip install --upgrade {package_name}")
681
+ except Exception as e:
682
+ print(f"\nError running update: {e}")
683
+ print(f"You can manually update with: pip install --upgrade {package_name}")
684
+
685
+
497
686
  class CodefetchParser(argparse.ArgumentParser):
498
687
  """Custom parser providing clean, intuitive help formatting."""
499
688
 
@@ -518,12 +707,13 @@ def build_parser():
518
707
  parser.add_argument("-R", "--repos", action="store_true", help="List public repositories for a user")
519
708
  parser.add_argument("-r", "--repo", type=str, help="Repository name or URL")
520
709
  parser.add_argument("-u", "--user", type=str, help="GitHub username")
521
- parser.add_argument("-b", "--branch", type=str, help="Branch name (default: main)")
710
+ parser.add_argument("-b", "--branch", type=str, default=None, help="Branch name (default: repository default branch)")
522
711
  parser.add_argument("-o", "--output", type=str, help="Custom output filename or path")
523
712
  parser.add_argument("--set-default", type=str, help="Save default repository (e.g. owner/repo)")
524
713
  parser.add_argument("--set-default-user", type=str, help="Save default GitHub user")
525
714
  parser.add_argument("--config", action="store_true", help="Display active configuration")
526
715
  parser.add_argument("--reset-config", action="store_true", help="Reset configuration back to defaults")
716
+ parser.add_argument("-U", "--update", action="store_true", help="Update CodeFetch to the latest version")
527
717
  parser.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
528
718
  parser.add_argument("-h", "--help", action="help", help="Show help message")
529
719
 
@@ -531,13 +721,13 @@ def build_parser():
531
721
 
532
722
 
533
723
  def resolve_invocation(positional_args, repo_flag, user_flag, branch_flag,
534
- default_owner, default_repo, default_branch):
724
+ default_owner, default_repo):
535
725
  """
536
726
  Resolve arguments into (owner, repo, branch, filename).
537
727
  """
538
728
  owner = user_flag or default_owner
539
729
  repo = default_repo
540
- branch = branch_flag or default_branch
730
+ branch = branch_flag # None if not provided
541
731
  filename = None
542
732
 
543
733
  if repo_flag:
@@ -593,10 +783,14 @@ def main():
593
783
  parser = build_parser()
594
784
  args = parser.parse_args()
595
785
 
786
+ # Handle --update
787
+ if args.update:
788
+ update_codefetch()
789
+ return
790
+
596
791
  config = load_config()
597
792
  default_owner = config.get("owner", DEFAULT_OWNER)
598
793
  default_repo = config.get("repo", DEFAULT_REPO)
599
- default_branch = config.get("branch", DEFAULT_BRANCH)
600
794
 
601
795
  # Handle --reset-config
602
796
  if args.reset_config:
@@ -632,12 +826,11 @@ def main():
632
826
  print("CodeFetch Configuration:")
633
827
  print(f" Default User: {default_owner or 'None (not set)'}")
634
828
  print(f" Default Repository: {default_repo or 'None (not set)'}")
635
- print(f" Default Branch: {default_branch}")
636
829
  print(f" Config File: {CONFIG_FILE}")
637
830
  return
638
831
 
639
832
  owner = args.user or default_owner
640
- branch = args.branch or default_branch
833
+ branch = args.branch # None if not provided
641
834
 
642
835
  # Handle --repos (list repositories)
643
836
  if args.repos:
@@ -682,7 +875,6 @@ def main():
682
875
  args.branch,
683
876
  default_owner,
684
877
  default_repo,
685
- default_branch,
686
878
  )
687
879
 
688
880
  if not resolved_filename:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: codefetch-cli
3
- Version: 1.2.3
3
+ Version: 1.2.5
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
@@ -35,6 +35,7 @@ CodeFetch is a lightweight command-line utility and Python library designed to q
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
+ - **Self-Updating**: Keep CodeFetch updated to the latest PyPI release with `codefetch --update`.
38
39
  - **Python Library Support**: Import and use programmatically with `import codefetch as cf`.
39
40
  - **Optional Persistent Defaults**: Save a default repository or user with `--set-default` if you frequently work with the same repository.
40
41
  - **Zero Runtime Dependencies**: Built using only the Python standard library with zero third-party dependencies.
@@ -129,6 +130,13 @@ codefetch --config
129
130
  codefetch --reset-config
130
131
  ```
131
132
 
133
+ ### 5. Self-Updating
134
+
135
+ ```bash
136
+ # Update CodeFetch to the latest version published on PyPI
137
+ codefetch --update
138
+ ```
139
+
132
140
  ---
133
141
 
134
142
  ## Python API Usage
@@ -163,11 +171,12 @@ print(f"Found {len(files)} files")
163
171
  | `-R`, `--repos` | List public repositories for a user | `codefetch -R -u username` |
164
172
  | `-r`, `--repo` | Specify repository name or URL | `codefetch -r repo main.py` |
165
173
  | `-u`, `--user` | Specify GitHub username or organization | `codefetch -u username -r repo main.py` |
166
- | `-b`, `--branch` | Branch name (default: `main`) | `codefetch -b master owner/repo Makefile` |
174
+ | `-b`, `--branch` | Branch name (default: repo default branch) | `codefetch -b master owner/repo Makefile` |
167
175
  | `--set-default` | Set persistent default repository | `codefetch --set-default owner/repo` |
168
176
  | `--set-default-user` | Set persistent default user | `codefetch --set-default-user username` |
169
177
  | `--config` | Display active configuration | `codefetch --config` |
170
178
  | `--reset-config` | Reset configuration back to defaults | `codefetch --reset-config` |
179
+ | `-U`, `--update` | Update CodeFetch to latest PyPI version | `codefetch --update` |
171
180
  | `-v`, `--version` | Display version information | `codefetch --version` |
172
181
  | `-h`, `--help` | Display help and usage message | `codefetch --help` |
173
182
 
@@ -0,0 +1,8 @@
1
+ codefetch/__init__.py,sha256=TnExw5rEX9F0Y72dvp-rNut71JfVHBT6jco4-x8nfmE,5049
2
+ codefetch/cli.py,sha256=UC-ZpV3kYJ2zuAmOCD64YgT3unnhPAMDhYQ4a5TNTUo,33929
3
+ codefetch_cli-1.2.5.dist-info/licenses/LICENSE,sha256=hqEPPm8jxPava5BfKIj3wTIXNyWNwsIvPv2daPCOijY,1094
4
+ codefetch_cli-1.2.5.dist-info/METADATA,sha256=gjQ8dwKPqb3v-AyGvurQV0oD6XX00SLulqvkFcbiYNU,6994
5
+ codefetch_cli-1.2.5.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
+ codefetch_cli-1.2.5.dist-info/entry_points.txt,sha256=jFLRsBB_USeSdqjewcXZfnOzY56s0PZat7mnYkYdrXg,49
7
+ codefetch_cli-1.2.5.dist-info/top_level.txt,sha256=kHrftHbyqugb8LQXjW1dR9uI-JFjZQJ0sUReIa45yXQ,10
8
+ codefetch_cli-1.2.5.dist-info/RECORD,,
@@ -1,8 +0,0 @@
1
- codefetch/__init__.py,sha256=4eDsjK1Bul-KXSSDOJqK2OsrS_2ckHtW_n8Z_jQiC5U,4542
2
- codefetch/cli.py,sha256=sftww8iPXrpcOfTnbULLXPN0AXoSzKA_S-TvjSiOrQc,25531
3
- codefetch_cli-1.2.3.dist-info/licenses/LICENSE,sha256=hqEPPm8jxPava5BfKIj3wTIXNyWNwsIvPv2daPCOijY,1094
4
- codefetch_cli-1.2.3.dist-info/METADATA,sha256=pIx0QNujNrvHrf9YgdB0w1NwxFrPrBF2362xcO_NQsQ,6675
5
- codefetch_cli-1.2.3.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
6
- codefetch_cli-1.2.3.dist-info/entry_points.txt,sha256=jFLRsBB_USeSdqjewcXZfnOzY56s0PZat7mnYkYdrXg,49
7
- codefetch_cli-1.2.3.dist-info/top_level.txt,sha256=kHrftHbyqugb8LQXjW1dR9uI-JFjZQJ0sUReIa45yXQ,10
8
- codefetch_cli-1.2.3.dist-info/RECORD,,