diff --git a/CHANGELOG.md b/CHANGELOG.md index e49e4f5..35e4c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## 1.1.0 (2025-10-21) + +- Add package name autocomplete for `dexi remove` and `dexi update` ([#8]()) +- Use pathlib for path manipulation ([#7]()) +- Stylize error messages ([#9]()) +- Replace default installation method with uv +- Fix error when installing a package that omits the `exclude` attribute +- Fix missing error checking for `dexi update` + +### Contributors + +- [@dormieriancitizen]() +- [@ethanthopkins]() + ## 1.0.0 (2025-10-08) -- Released DexI version 1.0.0 +- Release DexI version 1.0.0 + diff --git a/README.md b/README.md index abca6e3..0ddc9f2 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ [![CI](https://github.com/Dotsian/DexI/actions/workflows/CI.yml/badge.svg)](https://github.com/Dotsian/DexI/actions/workflows/CI.yml) [![Issues](https://img.shields.io/github/issues/Dotsian/DexI)](https://github.com/Dotsian/DexI/issues) -[![Version](https://img.shields.io/badge/version-1.0.0-blue)](https://github.com/Dotsian/DexI/blob/master/CHANGELOG.md) +[![Version](https://img.shields.io/badge/version-1.1.0-blue)](https://github.com/Dotsian/DexI/blob/master/CHANGELOG.md) Dex Inventory "DexI" is a Ballsdex package manager developed by DotZZ that provides developers with package control and easily allows users to add, remove, install, and update third-party packages. -_**App support will only be available after Ballsdex v2.29.4**_ +_**Packages with Django apps can only be downloaded from Ballsdex v2.29.5+**_ ## DexI vs. the Traditional Method @@ -18,10 +18,16 @@ Using DexI over the traditional method for package management is far better, as ## Installation -You can install and update DexI using [pip](https://www.python.org/downloads/): +You can install DexI using [uv](https://docs.astral.sh/uv/getting-started/installation/): ```bash -pip install git+https://github.com/Dotsian/DexI.git +uv tool install git+https://github.com/Dotsian/DexI +``` + +Updating DexI with uv: + +```bash +uv tool upgrade dexi ``` ## Usage diff --git a/dexi/__init__.py b/dexi/__init__.py index 1c11a6e..eee00e3 100644 --- a/dexi/__init__.py +++ b/dexi/__init__.py @@ -1 +1 @@ -__version__ = "1.0.0" +__version__ = "1.1.0" diff --git a/dexi/app.py b/dexi/app.py index 73457b0..85a548e 100644 --- a/dexi/app.py +++ b/dexi/app.py @@ -1,4 +1,5 @@ import typer +from typing_extensions import Annotated from .commands.installer import install_packages from .commands.manager import ( @@ -7,7 +8,7 @@ update_all_packages, update_package, ) -from .commands.viewer import list_packages +from .commands.viewer import autocomplete_packages, list_packages from .core.errors import Errors app = typer.Typer() @@ -31,7 +32,14 @@ def add(package: str, branch: str = "main"): @app.command() -def remove(package: str): +def remove( + package: Annotated[ + str, + typer.Argument( + help="The name of the package", autocompletion=autocomplete_packages + ), + ], +): """ Removes and uninstalls a package. @@ -46,7 +54,15 @@ def remove(package: str): @app.command() -def update(package: str | None = None): +def update( + package: Annotated[ + str, + typer.Argument( + help="The name of the package", autocompletion=autocomplete_packages + ), + ] + | None = None, +): """ Updates all packages or a specified package. @@ -56,7 +72,7 @@ def update(package: str | None = None): The package you want to update. Automatically updates all packages if not specified. """ - Errors(["invalid_project", "invalid_version", "no_config_found"]).check + Errors(["invalid_project", "invalid_version", "no_config_found"]).check() if package is None: update_all_packages() diff --git a/dexi/commands/installer.py b/dexi/commands/installer.py index f94f384..4689d97 100644 --- a/dexi/commands/installer.py +++ b/dexi/commands/installer.py @@ -3,6 +3,7 @@ import random import shutil import zipfile +from pathlib import Path from typing import cast import requests @@ -42,7 +43,7 @@ def uninstall_package(package: str): found_package = fetch_package(package, dexi_tool["packages"]) if found_package is None: - error(f"Could not find '{package}' package") + error(f"Could not find [red]'{package}'[/red] package") return data = Package.from_git(found_package["git"], found_package["branch"]) @@ -50,9 +51,9 @@ def uninstall_package(package: str): if data.app is not None and not app_operations_supported(): return - desination = f"{os.getcwd()}/ballsdex/packages/{data.package.target}" + destination = Path.cwd() / "ballsdex" / "packages" / data.package.target - if not os.path.isdir(desination): + if not destination.is_dir(): return if data.app is not None: @@ -65,7 +66,7 @@ def uninstall_package(package: str): remove_list_entry("packages", f"ballsdex.packages.{data.package.target}") - shutil.rmtree(desination) + shutil.rmtree(destination) def install_package( @@ -93,48 +94,45 @@ def install_package( author, repository = repository.split("/") zip_url = f"https://github.com/{author}/{repository}/archive/refs/heads/{branch}.zip" - desination = f"{os.getcwd()}/ballsdex/packages/{data.package.target}" + destination = Path.cwd() / "ballsdex" / "packages" / data.package.target name = package_name(repository, branch) - if os.path.isdir(desination): + if destination.is_dir(): if cancel_if_exists: return False replaced = True - shutil.rmtree(desination) + shutil.rmtree(destination) if data.app is not None: if not app_operations_supported(): error( - f"DexI packages with Django apps are not supported " - f"on Ballsdex v$BD_V, please update to v{SUPPORTED_APP_VERSION}+" + f"[red]DexI packages[/red] with Django apps are not supported on " + f"red]Ballsdex v$BD_V[/red], please update to v{SUPPORTED_APP_VERSION}+" ) - app_desination = f"{os.getcwd()}/admin_panel/{data.app.target}" + app_destination = Path.cwd() / "admin_panel" / data.app.target - if os.path.isdir(app_desination): + if app_destination.is_dir(): replaced = True - shutil.rmtree(app_desination) + shutil.rmtree(app_destination) - os.makedirs(app_desination, exist_ok=True) + app_destination.mkdir(parents=True, exist_ok=True) - os.makedirs(desination, exist_ok=True) + destination.mkdir(parents=True, exist_ok=True) response = requests.get(zip_url) if not response.ok: - error(f"Failed to fetch {name}") + error(f"Failed to fetch [red]{name}[/red]") with zipfile.ZipFile(io.BytesIO(response.content)) as z: base_folder = f"{repository}-{branch}/" for member in z.namelist(): if member[-7:] in ["LICENSE", "LICENCE"]: - with ( - z.open(member) as src, - open(f"{desination}/{member[-7:]}", "wb") as dst, - ): + with z.open(member) as src, (destination / member[-7:]).open("wb") as dst: shutil.copyfileobj(src, dst) continue @@ -147,7 +145,7 @@ def install_package( if not relative_path or relative_path in data.package.exclude: continue - target_path = os.path.join(desination, relative_path) + target_path = destination / relative_path if member.endswith("/"): os.makedirs(target_path, exist_ok=True) @@ -159,7 +157,12 @@ def install_package( shutil.copyfileobj(src, dst) if data.app is not None: # I'll refactor this later - app_desination = cast(str, app_desination) # type: ignore + if not app_destination: + # something has gone remarkably wrong + # (shouldnt be possible) + raise Exception( + "Somehow app_destination has gone missing while copying files" + ) for member in z.namelist(): if not member.startswith(f"{base_folder}{data.app.source}/"): @@ -170,15 +173,15 @@ def install_package( if not relative_path: continue - target_path = os.path.join(app_desination, relative_path) + target_path = app_destination / relative_path if member.endswith("/"): - os.makedirs(target_path, exist_ok=True) + target_path.mkdir(parents=True, exist_ok=True) continue - os.makedirs(os.path.dirname(target_path), exist_ok=True) + target_path.parent.mkdir(parents=True, exist_ok=True) - with z.open(member) as src, open(target_path, "wb") as dst: + with z.open(member) as src, target_path.open("wb") as dst: shutil.copyfileobj(src, dst) add_list_entry( diff --git a/dexi/commands/manager.py b/dexi/commands/manager.py index ad61dfe..d21da26 100644 --- a/dexi/commands/manager.py +++ b/dexi/commands/manager.py @@ -46,9 +46,9 @@ def add_package(package: str, branch: str): if installed_version not in specifier: error( - f"Ballsdex version requirement for '{package}' is set to " - f"'{data.ballsdex_version}', while this instance is on " - f"version '{ballsdex}'" + f"Ballsdex version requirement for [red]'{package}'[/red] is set to " + f"[red]'{data.ballsdex_version}'[/red], while this instance is on " + f"version [red]'{ballsdex}'[/red]" ) project = parse_pyproject() @@ -58,7 +58,7 @@ def add_package(package: str, branch: str): dexi = tool.setdefault("dexi", table()) if not initialized and fetch_package(package, fetch_all_packages()) is not None: - error("This package has already been added") + error("This [red]package[/red] has already been added") package_array = dexi.setdefault("packages", array().multiline(True)) @@ -113,7 +113,7 @@ def remove_package(package: str): package_entry = fetch_package(package, dexi_tool["packages"]) if package_entry is None: - error(f"Could not find '{package}' package") + error(f"Could not find [red]'{package}'[/red] package") return uninstall_package(package) @@ -176,12 +176,12 @@ def update_package(package: str | PackageEntry): dexi_project = parse_pyproject() if "tool" not in dexi_project or "dexi" not in dexi_project["tool"]: # type: ignore - error("pyproject.toml contains invalid DexI data") + error("[red]pyproject.toml[/red] contains invalid [red]DexI data[/red]") dexi_tool = dexi_project["tool"]["dexi"] # type: ignore if "packages" not in dexi_tool: # type: ignore - error("pyproject.toml contains invalid DexI data") + error("[[red]pyproject.toml[/red] contains invalid [red]DexI data[/red]") packages = dexi_tool["packages"] # type: ignore diff --git a/dexi/commands/viewer.py b/dexi/commands/viewer.py index be454e6..c99bbeb 100644 --- a/dexi/commands/viewer.py +++ b/dexi/commands/viewer.py @@ -3,6 +3,14 @@ from ..core.utils import console, fetch_all_packages, fetch_pyproject, package_name +def autocomplete_packages(incomplete: str) -> list[str]: + return [ + package["git"] + for package in fetch_all_packages() + if package["git"].startswith(incomplete) + ] + + def list_packages(hide_update: bool = False): """ Displays a list of packages. diff --git a/dexi/core/errors.py b/dexi/core/errors.py index 89cd1e0..edac371 100644 --- a/dexi/core/errors.py +++ b/dexi/core/errors.py @@ -1,5 +1,5 @@ -import os from dataclasses import dataclass, field +from pathlib import Path from packaging.version import parse as parse_version @@ -18,17 +18,17 @@ class Errors: @staticmethod def invalid_project() -> None: - if os.path.isdir("ballsdex") and os.path.isfile("pyproject.toml"): + if Path("ballsdex").is_dir() and Path("pyproject.toml").is_file(): return - error("Attempted to use DexI command on an invalid project") + error("Attempted to use [red]DexI[/red] command on an [red]invalid project[/red]") @staticmethod def no_config_found() -> None: - if os.path.isfile("config.yml"): + if Path("config.yml").is_file(): return - error("No 'config.yml' file detected") + error("No [red]'config.yml'[/red] file detected") @staticmethod def invalid_version() -> None: @@ -36,7 +36,7 @@ def invalid_version() -> None: return error( - "DexI does not support Ballsdex v$BD_V, please update to " + "DexI does not support [red]Ballsdex v$BD_V[/red], please update to " f"v{SUPPORTED_VERSION}+" ) diff --git a/dexi/core/package.py b/dexi/core/package.py index 452f2c3..6c3659c 100644 --- a/dexi/core/package.py +++ b/dexi/core/package.py @@ -44,22 +44,25 @@ class Package: def from_git(cls, package: str, branch: str) -> Self: if package.count("/") != 1: error( - "Invalid GitHub repository identifier entered; Expected " + "Invalid GitHub repository identifier entered; " + "Expected [red][/red]" ) data = fetch_pyproject(package, branch) if not data or "tool" not in data or "dexi" not in data["tool"]: - error(f"Could not locate {package_name(package, branch)}") + error(f"Could not locate [red]{package_name(package, branch)}[/red]") dexi_tool = data["tool"]["dexi"] dexi_package = dexi_tool["package"] if not dexi_tool.get("public", False): - error(f"Could not locate {package_name(package, branch)}") + error(f"Could not locate [red]{package_name(package, branch)}[/red]") package_config = PackageConfig( - dexi_package["source"], dexi_package["target"], dexi_package.get("exclude") + dexi_package["source"], + dexi_package["target"], + dexi_package.get("exclude", []) ) fields = { diff --git a/dexi/core/utils.py b/dexi/core/utils.py index 8528a9d..53a3b5d 100644 --- a/dexi/core/utils.py +++ b/dexi/core/utils.py @@ -1,6 +1,6 @@ -import os import re import sys +from pathlib import Path from typing import cast import requests @@ -50,7 +50,7 @@ def fetch_pyproject(package: str, branch: str) -> dict: return data -def parse_pyproject(path: str | None = None) -> TOMLDocument: +def parse_pyproject(path: Path | None = None) -> TOMLDocument: """ Parses a pyproject file and returns it. @@ -60,14 +60,14 @@ def parse_pyproject(path: str | None = None) -> TOMLDocument: The path that holds the pyproject file. """ if path is None: - path = os.getcwd() + path = Path.cwd() - path = f"{path}/pyproject.toml" + path = path / "pyproject.toml" - if not os.path.isfile(path): + if not path.is_file(): error("Failed to find [red]pyproject.toml[/red] in the current directory") - with open(path) as file: + with path.open() as file: return parse(file.read()) @@ -78,7 +78,7 @@ def app_operations_supported() -> bool: return parse_version(fetch_ballsdex_version()) >= parse_version(SUPPORTED_APP_VERSION) -def fetch_ballsdex_version(path: str | None = None) -> str: +def fetch_ballsdex_version(path: Path | None = None) -> str: """ Returns the Ballsdex version. @@ -88,14 +88,14 @@ def fetch_ballsdex_version(path: str | None = None) -> str: The path that will be checked. """ if path is None: - path = os.getcwd() + path = Path.cwd() - path = f"{path}/ballsdex/__init__.py" + path = path / "ballsdex/__init__.py" - if not os.path.isfile(path): + if not path.is_file(): error("Failed to find [red]ballsdex/__init__.py[/red] in the current directory") - with open(path) as file: + with path.open() as file: return file.read().replace('__version__ = "', "").rstrip()[:-1] @@ -150,7 +150,7 @@ def fetch_all_packages() -> list[PackageEntry]: return cast(list[PackageEntry], packages) -def add_list_entry(section: str, entry: str, path: str | None = None): +def add_list_entry(section: str, entry: str, path: Path | None = None): """ Adds an item to a list in the config file. @@ -164,9 +164,11 @@ def add_list_entry(section: str, entry: str, path: str | None = None): The config file path. """ if path is None: - path = os.getcwd() + path = Path.cwd() - with open(f"{path}/config.yml") as file: + path = path / "config.yml" + + with path.open() as file: lines = file.readlines() item = f" - {entry}\n" @@ -179,11 +181,11 @@ def add_list_entry(section: str, entry: str, path: str | None = None): lines.insert(i + 1, item) break - with open(f"{path}/config.yml", "w") as file: + with path.open("w") as file: file.writelines(lines) -def remove_list_entry(section: str, entry: str, path: str | None = None): +def remove_list_entry(section: str, entry: str, path: Path | None = None): """ Removes an item from a list in the config file. @@ -197,9 +199,11 @@ def remove_list_entry(section: str, entry: str, path: str | None = None): The config file path. """ if path is None: - path = os.getcwd() + path = Path.cwd() + + path = path / "config.yml" - with open(f"{path}/config.yml") as file: + with path.open() as file: lines = file.readlines() item = f" - {entry}\n" @@ -209,7 +213,7 @@ def remove_list_entry(section: str, entry: str, path: str | None = None): lines.remove(item) - with open(f"{path}/config.yml", "w") as file: + with path.open("w") as file: file.writelines(lines) diff --git a/pyproject.toml b/pyproject.toml index 4c24bf2..0e28f99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "DexI" -version = "1.0.0" +version = "1.1.0" description = 'Dex Inventory "DexI" package manager for Ballsdex' requires-python = ">=3.12" license = "MIT"