Skip to content

Commit 6aff9ed

Browse files
committed
Add list command to pybite
Signed-off-by: Alireza Poodineh <[email protected]>
1 parent 8289064 commit 6aff9ed

File tree

4 files changed

+306
-19
lines changed

4 files changed

+306
-19
lines changed

tools/pybite/download.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
import os
2+
import zipfile
3+
import tempfile
4+
import time
5+
import shutil
6+
from urllib.parse import urlparse
7+
import uuid
8+
from typing import Optional
9+
10+
CACHE_DURATION = 7200 # seconds
11+
GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", None)
12+
_temp_folders: list[str] = []
13+
14+
def _get_temp_base() -> str:
15+
base = os.path.join(tempfile.gettempdir(), "pybite")
16+
os.makedirs(base, exist_ok=True)
17+
return base
18+
19+
def _create_temp_folder() -> str:
20+
folder = os.path.join(_get_temp_base(), str(uuid.uuid4()))
21+
os.makedirs(folder, exist_ok=True)
22+
_temp_folders.append(folder)
23+
return folder
24+
25+
def _cleanup_temp_folders() -> None:
26+
for folder in _temp_folders:
27+
if os.path.isdir(folder):
28+
shutil.rmtree(folder, ignore_errors=True)
29+
_temp_folders.clear()
30+
base = _get_temp_base()
31+
now = time.time()
32+
for fname in os.listdir(base):
33+
fpath = os.path.join(base, fname)
34+
if fname.endswith(".zip") and os.path.isfile(fpath):
35+
if now - os.path.getmtime(fpath) > CACHE_DURATION:
36+
try:
37+
os.remove(fpath)
38+
except Exception:
39+
pass
40+
41+
def _parse_github_url(url: str) -> tuple[str, str, str, str]:
42+
if url.startswith("github://") or url.startswith("gh://"):
43+
url = "https://github.com/" + url.split("://", 1)[1]
44+
parts = urlparse(url)
45+
path = parts.path.strip("/").split('/')
46+
if len(path) < 2:
47+
raise ValueError(f"Invalid GitHub URL: {url}")
48+
owner, repo = path[0], path[1].removesuffix('.git')
49+
branch = 'main'
50+
sub_path = ''
51+
if len(path) >= 4 and path[2] == 'tree':
52+
branch = path[3]
53+
sub_path = '/'.join(path[4:]) if len(path) > 4 else ''
54+
return owner, repo, branch, sub_path
55+
56+
def _is_local_path(path: str) -> bool:
57+
parts = urlparse(path)
58+
if parts.scheme or parts.netloc:
59+
return False
60+
if os.path.isfile(path):
61+
raise ValueError(f"Local path is a file, not a directory: {path}")
62+
return True
63+
64+
def _copy_local_folder(src: str, dest: str) -> None:
65+
if not os.path.isdir(src):
66+
raise ValueError(f"Local path does not exist or is not a directory: {src}")
67+
if os.path.exists(dest) and os.listdir(dest):
68+
raise ValueError(f"Destination folder '{dest}' is not empty.")
69+
if not os.path.exists(dest):
70+
shutil.copytree(src, dest)
71+
else:
72+
for root, dirs, files in os.walk(src):
73+
rel = os.path.relpath(root, src)
74+
target_root = os.path.join(dest, rel) if rel != '.' else dest
75+
os.makedirs(target_root, exist_ok=True)
76+
for f in files:
77+
shutil.copy2(os.path.join(root, f), os.path.join(target_root, f))
78+
79+
def _print_progress(filename: str, downloaded: int, total: int) -> None:
80+
if total:
81+
percent = downloaded * 100 // total
82+
print(f"\rDownloading {filename}: {percent}%", end="", flush=True)
83+
84+
def _extract_folder_from_zip(zip_path: str, folder_path: str, dest_dir: str, repo: str, branch: str) -> None:
85+
prefix = f"{repo}-{branch}/{folder_path.rstrip('/')}/"
86+
with zipfile.ZipFile(zip_path) as z:
87+
for member in z.namelist():
88+
if not member.startswith(prefix):
89+
continue
90+
rel = member[len(prefix):]
91+
if not rel:
92+
continue
93+
target = os.path.join(dest_dir, rel)
94+
if member.endswith('/'):
95+
os.makedirs(target, exist_ok=True)
96+
else:
97+
os.makedirs(os.path.dirname(target), exist_ok=True)
98+
with z.open(member) as src, open(target, 'wb') as dst:
99+
dst.write(src.read())
100+
101+
def _download_github_api(owner: str, repo: str, branch: str, folder_path: str, dest_dir: str) -> None:
102+
import requests
103+
headers = {'Accept': 'application/vnd.github.v3+json'}
104+
if GITHUB_TOKEN:
105+
headers['Authorization'] = f"token {GITHUB_TOKEN}"
106+
def _download_dir(path: str, dest: str) -> None:
107+
os.makedirs(dest, exist_ok=True)
108+
api_url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}?ref={branch}" if path else f"https://api.github.com/repos/{owner}/{repo}/contents?ref={branch}"
109+
r = requests.get(api_url, headers=headers)
110+
r.raise_for_status()
111+
items = r.json()
112+
for item in items:
113+
if item['type'] == 'file':
114+
file_dest = os.path.join(dest, item['name'])
115+
print(f"Downloading file {item['path']}")
116+
download_file(item['download_url'], file_dest)
117+
elif item['type'] == 'dir':
118+
_download_dir(item['path'], os.path.join(dest, item['name']))
119+
_download_dir(folder_path, dest_dir)
120+
121+
def _download_github_zip(owner: str, repo: str, branch: str, folder_path: str, dest_dir: str) -> None:
122+
zip_url = f"https://github.com/{owner}/{repo}/archive/{branch}.zip"
123+
base = _get_temp_base()
124+
cache_name = f"github_{owner}_{repo}_{branch}.zip"
125+
cache_path = os.path.join(base, cache_name)
126+
if os.path.exists(cache_path) and (time.time() - os.path.getmtime(cache_path)) < CACHE_DURATION:
127+
print(f"Using cached file at {cache_path}")
128+
else:
129+
print(f"Downloading file from {zip_url}")
130+
download_file(zip_url, cache_path)
131+
print(f"Extracting '{folder_path}' into '{dest_dir}'")
132+
_extract_folder_from_zip(cache_path, folder_path, dest_dir, repo, branch)
133+
134+
def _download_github_folder(url: str, dest_dir: str) -> None:
135+
if os.path.exists(dest_dir) and os.listdir(dest_dir):
136+
raise ValueError(f"Destination folder '{dest_dir}' is not empty.")
137+
owner, repo, branch, folder_path = _parse_github_url(url)
138+
try:
139+
import requests # noqa
140+
_download_github_api(owner, repo, branch, folder_path, dest_dir)
141+
except ImportError:
142+
print("requests library not found, downloading the zip file instead.")
143+
_download_github_zip(owner, repo, branch, folder_path, dest_dir)
144+
print("Download complete.")
145+
146+
def download_file(url: str, dest_path: str, show_progress: bool = True) -> None:
147+
"""
148+
Download a file from a URL to a local path.
149+
150+
Args:
151+
url: The URL to download from.
152+
dest_path: The local file path to write to.
153+
show_progress: If True, print download progress.
154+
Raises:
155+
Exception: If download fails.
156+
"""
157+
try:
158+
import requests
159+
resp = requests.get(url, stream=True)
160+
resp.raise_for_status()
161+
total = int(resp.headers.get('content-length', 0))
162+
downloaded = 0
163+
with open(dest_path, 'wb') as f:
164+
for chunk in resp.iter_content(chunk_size=8192):
165+
if not chunk:
166+
continue
167+
f.write(chunk)
168+
if show_progress and total:
169+
downloaded += len(chunk)
170+
_print_progress(os.path.basename(dest_path), downloaded, total)
171+
if show_progress and total:
172+
print()
173+
except ImportError:
174+
from urllib.request import Request, urlopen
175+
req = Request(url, headers={"User-Agent": "python-urllib"})
176+
with urlopen(req) as resp:
177+
total = resp.length or 0
178+
downloaded = 0
179+
with open(dest_path, 'wb') as f:
180+
while True:
181+
chunk = resp.read(8192)
182+
if not chunk:
183+
break
184+
f.write(chunk)
185+
if show_progress and total:
186+
downloaded += len(chunk)
187+
_print_progress(os.path.basename(dest_path), downloaded, total)
188+
if show_progress and total:
189+
print()
190+
191+
def download_folder(src: str, dest_dir: str) -> None:
192+
"""
193+
Download or copy a folder from a GitHub URL or local directory to a destination directory.
194+
195+
Args:
196+
src: Source GitHub URL or local directory path.
197+
dest_dir: Destination directory path.
198+
Raises:
199+
ValueError: If the destination exists and is not empty, or if the source is invalid.
200+
"""
201+
if os.path.exists(dest_dir) and os.listdir(dest_dir):
202+
raise ValueError(f"Destination folder '{dest_dir}' is not empty.")
203+
if _is_local_path(src):
204+
_copy_local_folder(src, dest_dir)
205+
else:
206+
_download_github_folder(src, dest_dir)

tools/pybite/handlers.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def handle_bite_run(host: Host, args: argparse.Namespace, extras: List[str]) ->
2929
host.get_argparser().error("The 'list' option cannot be used with other arguments.")
3030

3131
dependant_targets: List[MSBuildTarget] = []
32-
targets = host._get_bite_core_targets()
32+
targets = host.get_bite_core_targets()
3333
print("Available independent targets:")
3434
for target in targets:
3535
if getattr(target, 'AfterTargets', None) is None and getattr(target, 'BeforeTargets', None) is None:
@@ -48,3 +48,25 @@ def handle_bite_run(host: Host, args: argparse.Namespace, extras: List[str]) ->
4848
return
4949
target = getattr(args, 'target', 'help')
5050
host.run_bite(target, *extras)
51+
52+
def handle_bite_list(host: Host, args: argparse.Namespace, extras: List[str]) -> None:
53+
"""
54+
Handle the 'list' command, listing available modules.
55+
"""
56+
if extras:
57+
host.get_argparser().error("The 'list' option does not accept any extra arguments.")
58+
59+
verbose = getattr(args, 'verbose', False)
60+
modules = host.get_modules()
61+
print("Available modules:")
62+
for module in modules.values():
63+
if not verbose:
64+
print(f" {module.id}"
65+
f"{f' ({module.name})' if module.name else ''}"
66+
f"{f' - {module.description}' if module.description else ''}")
67+
else:
68+
print(f" {module.id}")
69+
print(f" Name: {module.name}")
70+
print(f" Description: {module.description}")
71+
print(f" Path: {module.path}")
72+
print()

tools/pybite/host.py

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from .global_json import GlobalJson
1111
from .msbuild import MSBuildFile, MSBuildTarget
12+
from .module import Module
1213

1314

1415
class Host:
@@ -126,8 +127,8 @@ def get_argparser(self) -> argparse.ArgumentParser:
126127
parser.set_defaults(command='build')
127128
self._subparsers_action = subparsers
128129

129-
# Register built-in dotnet commands
130-
for cmd in self.DOTNET_COMMANDS:
130+
# Register built-in dotnet commands (sorted by name)
131+
for cmd in sorted(self.DOTNET_COMMANDS, key=lambda c: c['name']):
131132
subparsers.add_parser(
132133
cmd['name'],
133134
help=cmd['help'],
@@ -137,7 +138,25 @@ def get_argparser(self) -> argparse.ArgumentParser:
137138
)
138139
self.register_handler(cmd['name'], handlers.handle_dotnet_builtin)
139140

140-
# Register bite command only if bite.proj exists
141+
subparsers.add_parser(
142+
'dotnet',
143+
help='Run a dotnet command',
144+
epilog=self.argparser_epilog + ' (Dotnet CLI)',
145+
usage=self.argparser_usage.replace('command', 'dotnet') + ' [command]',
146+
add_help=False,
147+
)
148+
self.register_handler('dotnet', handlers.handle_dotnet_cli)
149+
150+
list_parser = subparsers.add_parser(
151+
'list',
152+
help='List all bite modules',
153+
usage=self.argparser_usage.replace('command', 'list'),
154+
)
155+
156+
list_parser.add_argument('-v', '--verbose', action='store_true', help='Show verbose output')
157+
self.register_handler('list', handlers.handle_bite_list)
158+
159+
# Register run command only if bite.proj exists
141160
if os.path.isfile(self.BITE_PROJ_PATH):
142161
run_parser = subparsers.add_parser(
143162
'run',
@@ -146,18 +165,9 @@ def get_argparser(self) -> argparse.ArgumentParser:
146165
usage=self.argparser_usage.replace('command', 'run') + ' [target]',
147166
)
148167
run_parser.add_argument('target', nargs='?', default='help', help='bite.core target to run, default is "help"')
149-
run_parser.add_argument('--list', '-l', action='store_true', help='List available targets')
168+
run_parser.add_argument('-l', '--list', action='store_true', help='List available targets')
150169
self.register_handler('run', handlers.handle_bite_run)
151170

152-
subparsers.add_parser(
153-
'dotnet',
154-
help='Run a dotnet command',
155-
epilog=self.argparser_epilog + ' (Dotnet CLI)',
156-
usage=self.argparser_usage.replace('command', 'dotnet') + ' [command]',
157-
add_help=False,
158-
)
159-
self.register_handler('dotnet', handlers.handle_dotnet_cli)
160-
161171
self.argparser = parser
162172
return self.argparser
163173

@@ -394,7 +404,7 @@ def msbuild_path(path: str) -> str:
394404
msbuild_path = f'"{msbuild_path}"'
395405
return msbuild_path
396406

397-
def _get_bite_core_targets(self) -> List[MSBuildTarget]:
407+
def get_bite_core_targets(self) -> List[MSBuildTarget]:
398408
"""
399409
Retrieve all available MSBuild targets from bite.core or .bite.targets files.
400410
@@ -424,14 +434,28 @@ def _get_bite_core_targets(self) -> List[MSBuildTarget]:
424434

425435
return targets
426436

427-
def load_modules(self) -> Dict[str, Any]:
437+
def get_modules(self) -> Dict[str, Module]:
438+
"""
439+
Get all modules from the modules directory.
440+
"""
441+
mods: Dict[str, Module] = {}
442+
443+
for root, dirs, files in os.walk(self.MODULES_DIR):
444+
for dir in dirs:
445+
path = os.path.join(root, dir)
446+
mod = Module(path, require_json=False)
447+
mods[mod.id] = mod
448+
449+
return mods
450+
451+
def load_modules(self) -> List[Any]:
428452
"""
429453
Load all .bite.py modules from the modules directory.
430454
431455
Returns:
432-
Dict[str, Any]: Mapping of plugin names to loaded module objects.
456+
List[Any]: List of loaded module objects.
433457
"""
434-
mods: Dict[str, Any] = {}
458+
mods: List[Any] = []
435459
pattern = os.path.join(self.MODULES_DIR, '**', '*.bite.py')
436460
for path in glob.glob(pattern, recursive=True):
437461
name = os.path.splitext(os.path.basename(path))[0]
@@ -446,7 +470,7 @@ def load_modules(self) -> Dict[str, Any]:
446470
continue
447471
if hasattr(mod, 'load'):
448472
try:
449-
mods[name] = mod.load(self)
473+
mods.append(mod.load(self))
450474
except Exception as e:
451475
print(f"Module '{name}' failed to initialize: {e}")
452476
return mods

tools/pybite/module.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# write a class named module that accepts a path to a directory and then opens a json file and stores parsed object and extracts name and description
2+
import os
3+
import json
4+
from typing import Optional, Dict, Any
5+
6+
class Module:
7+
"""
8+
A class representing a module in the Bite build engine.
9+
It loads a JSON file from the specified directory and extracts its information.
10+
"""
11+
def __init__(self, path: str, require_json: bool = True) -> None:
12+
self.path = path
13+
self.id: str = os.path.basename(path)
14+
self.name: Optional[str] = None
15+
self.description: Optional[str] = None
16+
self.module_info: Optional[Dict[str, Any]] = None
17+
try:
18+
self._load_json()
19+
except:
20+
if require_json:
21+
raise
22+
23+
def _load_json(self) -> None:
24+
"""
25+
Load the JSON file from the specified path and extract name and description.
26+
"""
27+
json_file_path = os.path.join(self.path, 'module.json')
28+
if not os.path.exists(json_file_path):
29+
raise FileNotFoundError(f"JSON file not found at {json_file_path}")
30+
31+
with open(json_file_path, 'r') as json_file:
32+
data: Dict[str, Any] = json.load(json_file)
33+
self.name = data.get('name')
34+
self.description = data.get('description')
35+
self.module_info = data

0 commit comments

Comments
 (0)