This commit is contained in:
2026-01-23 16:39:42 +11:00
12 changed files with 32 additions and 15 deletions

1
dig/__init__.py Normal file
View File

@@ -0,0 +1 @@
from .main import main

134
dig/build.py Normal file
View File

@@ -0,0 +1,134 @@
from .util import *
import shutil
import os, sys
import subprocess
import configparser
from rich.console import Console
console = Console()
def find_c_files(path: str):
paths = []
for entry in os.listdir(path):
full_path = os.path.join(path, entry)
if os.path.isdir(full_path):
find_c_files(full_path)
else:
if full_path.endswith(".c"):
paths.append(full_path)
return paths
def build_mineral(args):
# sanity checks
if not os.path.isdir(args.folder_path):
console.print("[b red]digpkg: failed to build mineral: specified folder doesn't exist![/]")
sys.exit(1)
if os.path.isfile(os.path.join(args.folder_path, "main.so")):
console.print("[b red]digpkg: failed to build mineral: attempt to build compiled mineral![/]")
sys.exit(1)
if not shutil.which("gcc"):
console.print("[b red]digpkg: failed to build mineral: gcc was not found![/]")
sys.exit(1)
# use gcc to compile the mineral
with console.status("Compiling", spinner="bouncingBall", spinner_style="green") as status:
c_files = find_c_files(args.folder_path)
print(len(c_files))
if len(c_files) == 0:
console.print("[b red]digpkg: failed to build mineral: no .c files found in the specified folder, are you sure you're compiling a mineral?[/]")
sys.exit(1)
if subprocess.run([
"gcc",
"-shared",
"-fPIC",
*c_files,
*[f"-{arg}" for arg in (args.gcc_args or [])],
"-o",
"main.so"
], stdout=None).returncode:
console.print("[b red]digpkg: failed to build mineral: gcc exited with non-zero exit code.[/]")
sys.exit(1)
console.print("[:white_check_mark:] Compile success!")
def build(args):
if args.package:
# build the mineral
build_mineral(args)
# create the build dir and throw in the .so file
with console.status("Packaging...", spinner="bouncingBall", spinner_style="green"):
build_dir = f"{os.path.basename(args.folder_path)}_build"
if not os.path.isdir(build_dir):
os.mkdir(build_dir)
# generate a mineral.ini file
config_parser = configparser.ConfigParser()
config_parser["package"] = {
"description": "Your description here",
"version": "1.0.0",
"config_version": "1",
}
config_parser["dependencies"] = {}
# write it to our new mineral
with open(os.path.join(build_dir, "mineral.ini"), "w") as f:
config_parser.write(f)
# generate doc files
docs_folder = os.path.join(build_dir, "docs")
if not os.path.isdir(docs_folder):
os.mkdir(docs_folder)
with open(os.path.join(docs_folder, "my_function.md"), "w") as f:
f.write("# mylib_MyFunction\n")
f.write("Describe your function briefly.\n\n")
f.write("## Arguments\n")
f.write("- myArgument (double): describe your arguments in a list format like this.\n\n")
f.write("## Returns\n")
f.write("result (int): then explain what the function returns\n\n")
f.write("## Raises\n")
f.write("- `ErrorName`: have your error reason here, include this if your function raises any errors\n")
f.write("## Example\n")
f.write("```python\n")
f.write("# then show how you use the function in an example\n")
f.write("call !mylib_MyFunction 123.0 &result\n")
f.write("```\n")
f.write("`Using \"python\" as the syntax highlighting language seems to work well with Ground.`")
with open(os.path.join(build_dir, "SUMMARY.md"), "w") as f:
f.write("# mylib\n")
f.write("Introduce your module here!\n\nThis file will serve as an index page for all your docs.\n\n")
f.write("## Subtitle (use this for categories)\n")
f.write("- [mylib_MyFunction](docs/my_function.md)\n\n")
f.write("## Changelog\n")
f.write("### v0.0.1 (latest)\n")
f.write("- Initial release.\n")
console.print("[:white_check_mark:] Generated a new package for you!")
shutil.move("main.so", os.path.join(build_dir, "main.so"))
console.print("[:white_check_mark:] Put your library into your package!")
else:
check_sudo()
check_ground_libs_path()
# build the mineral and move it straight to the ground libs folder
build_mineral(args)
with console.status("Installing...", spinner="bouncingBall", spinner_style="green"):
shutil.move("main.so", os.path.join(os.getenv("GROUND_LIBS"), f"{os.path.basename(args.folder_path)}.so"))
console.print("[:white_check_mark:] Installed!")

57
dig/docs.py Normal file
View File

@@ -0,0 +1,57 @@
from .util import *
import os, sys
from rich.console import Console
from textual.app import App, ComposeResult
from textual.widgets import MarkdownViewer, Header, Footer
console = Console()
class DocsApp(App):
TITLE = "Digpkg Docs"
SUB_TITLE = "made with ❤️ by SpookyDervish"
def __init__(self, inital_markdown_path: str):
super().__init__()
self.inital_markdown_path = inital_markdown_path
def compose(self) -> ComposeResult:
with open(self.inital_markdown_path, "r") as f:
markdown = f.read()
yield Header()
yield MarkdownViewer(markdown)
yield Footer()
def docs(args):
check_ground_libs_path()
mineral_path = os.path.join(os.getenv("GROUND_LIBS"), args.mineral_name)
if not os.path.isdir(mineral_path):
console.print(f"[b red]digpkg: can't read docs: the mineral [i]{args.mineral_name}[/] was not found!")
sys.exit(1)
docs_path = os.path.join(mineral_path, "docs")
summary_md_file = os.path.join(mineral_path, "SUMMARY.md")
if args.doc_file != None:
if not os.path.isfile(os.path.join(docs_path, f"{args.doc_file}.md")):
console.print(f"[b red]digpkg: can't read docs: the mineral [i]{args.mineral_name}[/] has no doc file named \"{args.doc_file}\".")
sys.exit(1)
os.chdir(docs_path)
app = DocsApp(f"{args.doc_file}.md")
app.run()
sys.exit(0)
if os.path.isfile(summary_md_file):
os.chdir(mineral_path)
app = DocsApp(summary_md_file)
app.run()
sys.exit(0)
else:
console.print(f"[b red]digpkg: can't read docs: the mineral [i]{args.mineral_name}[/] has no file named SUMMARY.md, please use the [i]--doc-file[/] argument to specify a specific doc to open instead.")
sys.exit(1)

116
dig/install.py Normal file
View File

@@ -0,0 +1,116 @@
import requests
import os
import sys
import tempfile
import tarfile
import configparser
import shutil
from rich.console import Console
from rich.progress import SpinnerColumn
from .util import check_ground_libs_path, check_sudo
console = Console()
def install_package(package_name, version, args, is_dependency: bool = False):
retries_left = args.max_retries
with console.status("Downloading tarball...", spinner="bouncingBall", spinner_style="blue") as status:
console.print(f"Installing {package_name} [d]({version})[/]")
while retries_left > 0:
# grab the tar ball
response = requests.get(f"https://chookspace.com/api/packages/ground/generic/{package_name}/{version}/mineral.tar")
# check response code for errors
if response.status_code == 404: # package doesn't exist
console.print(f"[b red]digpkg: mineral \"{package_name}\" was not found. Check to make sure the name and version number are correct.[/]")
sys.exit(1)
elif response.status_code != 200:
retries_left -= 1
console.print(f"[b yellow]digpkg: failed to download mineral \"{package_name}\": {response.content.decode()} ({retries_left} retries left)[/]")
if retries_left == 0:
console.print(f"[b red]digpkg: exceeded max retries while downloading mineral \"{package_name}\"[/]")
sys.exit(1)
continue
response.raise_for_status()
break
# create temporary file for tarball
try:
f = tempfile.TemporaryFile("wb+")
f.write(response.content)
f.flush()
f.seek(0)
console.print("[d][:white_check_mark:] Tarball downloaded![/]")
except KeyboardInterrupt:
console.print("[b yellow]digpkg: operation cancelled by user[/]")
return
# extract the tarball to the GROUND_LIBS folder
status.update("Extracting...")
extract_dir = os.getenv("GROUND_LIBS")
if not os.path.isdir(extract_dir): # gotta ensure the folder exists
os.mkdir(extract_dir)
tar_file = tarfile.open(fileobj=f)
tar_file.extractall(extract_dir)
f.close()
console.print(f"[d][:white_check_mark:] Extracted to {extract_dir}.")
console.status("Finishing up...")
package_folder = os.path.join(extract_dir, f"{package_name}/")
if not os.path.isfile(os.path.join(package_folder, "mineral.ini")):
console.print(f"[b red]digpkg: failed to install {package_name}: the mineral doesn't have a mineral.ini file, please contact the maintainer because they've set up the mineral incorrectly.")
console.print("[d]Cleaning up failed install...[/]")
shutil.rmtree(os.path.join(extract_dir, package_name))
sys.exit(1)
config_parser = configparser.ConfigParser()
config_parser.read(os.path.join(package_folder, "mineral.ini"))
dependencies = config_parser["dependencies"]
# create a symlink from the main.so file to the ground libs folder so ground can find it
symlink_path = os.path.join(extract_dir, f"{package_name}.so") # the path where the symlink is
if not os.path.isfile(symlink_path):
os.symlink(os.path.join(extract_dir, package_name, "main.so"), symlink_path)
if is_dependency:
console.print(f"[d][:white_check_mark:] Done installing dependency: {package_name}[/]")
for dependency, dependency_version in dependencies.items():
install_package(dependency, dependency_version, args, True)
if not is_dependency:
console.print(f"\n[b green][:white_check_mark:] Installed {package_name}![/]")
def install(args):
check_sudo()
check_ground_libs_path()
for package in args.names:
# figure out which version to install
package_name = package
version = "1.0.0"
if "@" in package:
split = package.split("@")
package_name = split[0]
version = split[1]
else:
console.print(f"[b red]digpkg: failed to install package: please specify version to install for {package_name}[/]")
sys.exit(1)
install_package(package_name, version, args)

41
dig/list.py Normal file
View File

@@ -0,0 +1,41 @@
import os
import configparser
from rich import print
from rich.table import Table
from .util import check_ground_libs_path
def list_cmd(args):
check_ground_libs_path()
ground_libs_folder = os.getenv("GROUND_LIBS")
folders = []
if os.path.isdir(ground_libs_folder): # used to prevent errors that are caused by the GROUND_LIBS folder not existing
folders = os.listdir(ground_libs_folder)
table = Table("Name", "Version", "Description", title="Installed")
config_parser = configparser.ConfigParser()
for folder in folders:
full_path = os.path.join(ground_libs_folder, folder)
# skip anything that isnt a folder
if not os.path.isdir(full_path):
continue
# read the mineral.ini file to figure out the version and description
ini_path = os.path.join(full_path, "mineral.ini")
if not os.path.isfile(ini_path):
continue
config_parser.read(ini_path)
table.add_row(
f"[b]{folder}",
f"[blue]{config_parser.get('package', 'version')}",
config_parser.get("package", "description"),
)
print(table)

78
dig/main.py Normal file
View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
import argparse
import os, sys
from .install import install
from .publish import publish
from .remove import remove
from .list import list_cmd
from .uninstall import uninstall
from .build import build
def parse_arguments():
# create our subcommands and args
arg_parser = argparse.ArgumentParser(prog="Digpkg", description="The package manager for the Ground programming language.")
sub_parsers = arg_parser.add_subparsers(dest="command")
# install command
install_command = sub_parsers.add_parser(name="install", description="install a mineral")
install_command.add_argument("names", help="name of the minerals to install", nargs="+")
install_command.add_argument("--max-retries", help="max number of download retries before giving up", default=3, type=int)
# uninstall command
uninstall_command = sub_parsers.add_parser(name="uninstall", description="uninstall a mineral")
uninstall_command.add_argument("name", help="name of the mineral to uninstall")
# list command
list_command = sub_parsers.add_parser(name="list", description="list all minerals installed in the current environment")
# publish command
publish_command = sub_parsers.add_parser(name="publish", description="publish a package to the repository")
#publish_command.add_argument("name", help="name and version of the package")
publish_command.add_argument("folder_path", help="path to the folder that will be uploaded")
# remove command
remove_command = sub_parsers.add_parser(name="remove", description="remove a published package from the repository")
remove_command.add_argument("name", help="name and version of the package")
# build command
build_command = sub_parsers.add_parser(name="build", description="build a folder as a mineral and either install it or prepare it for publishing")
build_command.add_argument("folder_path", help="path to the folder to build")
build_command.add_argument("--gcc-args", nargs="*", help="any extra args you want to give to gcc")
build_command.add_argument("--package", action="store_true", help="generate a folder with a mineral.ini and all the other files you need to publish the package")
# docs command
docs_command = sub_parsers.add_parser(name="docs", description="read the docs of a mineral")
docs_command.add_argument("mineral_name", help="name of the mineral you want to read the docs of")
docs_command.add_argument("-d", "--doc-file", help="load a specific doc file")
# parse arguments are run the command we chose
args = arg_parser.parse_args()
if not args.command:
arg_parser.print_help()
sys.exit(0)
if args.command == "install":
install(args)
elif args.command == "publish":
publish(args)
elif args.command == "remove":
remove(args)
elif args.command == "list":
list_cmd(args)
elif args.command == "uninstall":
uninstall(args)
elif args.command == "build":
build(args)
elif args.command == "docs":
from .docs import docs
docs(args)
def main():
parse_arguments()
if __name__ == "__main__":
main()

90
dig/publish.py Normal file
View File

@@ -0,0 +1,90 @@
import configparser
import tarfile
import tempfile
import os, sys
import requests
from requests.auth import HTTPBasicAuth
from rich.console import Console
from rich.prompt import Prompt
console = Console()
def publish(args):
mineral_name = os.path.basename(args.folder_path)
# sanity checks
if not os.path.isdir(args.folder_path):
console.print(f"[b red]digpkg: failed to publish mineral: \"{args.folder_path}\" is not a directory")
sys.exit(1)
if not os.path.isfile(os.path.join(args.folder_path, "mineral.ini")):
console.print(f"[b red]digpkg: failed to publish mineral: mineral has no \"mineral.ini\" file")
sys.exit(1)
if os.path.basename(os.path.normpath(args.folder_path)).endswith("_build"):
console.print(f"\n[b yellow]You didn't remove the \"_build\" suffix from your mineral's folder name!\n\nIf this is intentional you can ignore this message, however it is bad practice.\nIf this is not intentional, you will be unable to install your package properly using dig.[/]")
config_parser = configparser.ConfigParser()
config_parser.read(os.path.join(args.folder_path, "mineral.ini"))
version = config_parser["package"]["version"]
# ask for user and pass
console.print("[b]Please authenticate.\n[/]")
try:
username = Prompt.ask("Username", console=console)
password = Prompt.ask("Password (or PAT)", console=console, password=True)
except KeyboardInterrupt:
return
console.print()
with console.status("Authenticating...", spinner="bouncingBall", spinner_style="blue") as status:
# check if we have permission to link the package to the repo
repo_perms_request = requests.get(
url=f"https://chookspace.com/api/v1/users/{username}/orgs/ground/permissions",
auth=HTTPBasicAuth(username, password)
)
if repo_perms_request.status_code == 401:
console.print(f"[b red]digpkg: failed to publish mineral: checking authorization failed: invalid password[/b red]")
sys.exit(1)
elif not repo_perms_request.ok:
console.print(f"[b red]digpkg: failed to publish mineral: checking authorization failed: {repo_perms_request.content.decode()}[/b red]")
sys.exit(1)
# compress to a tar file
console.status("Compressing", spinner_style="green")
f = tempfile.TemporaryFile(mode="wb+")
with tarfile.open(fileobj=f, mode="w:gz") as tar_file:
tar_file.add(args.folder_path, arcname=os.path.basename(args.folder_path))
f.flush()
f.seek(0)
console.print("[d][:white_check_mark:] Compressed![/]")
# send the request
status.update("Uploading...", spinner_style="blue")
response = requests.put(
url=f"https://chookspace.com/api/packages/ground/generic/{mineral_name}/{version}/mineral.tar",
data=f,
auth=HTTPBasicAuth(username, password)
)
f.close()
match response.status_code:
case 401:
console.print("[b red]digpkg: failed to publish mineral: authentication failed[/]")
sys.exit(1)
case 400:
console.print("[b red]digpkg: failed to publish mineral: the package name or version number are invalid[/]")
sys.exit(1)
case 409:
console.print("[b red]digpkg: failed to publish mineral: that version number is already in use[/]")
sys.exit(1)
response.raise_for_status()
console.print("[d][:white_check_mark:] Uploaded![/]")
console.print("[:white_check_mark:] Done!")

45
dig/remove.py Normal file
View File

@@ -0,0 +1,45 @@
import requests
import sys
from requests.auth import HTTPBasicAuth
from rich.console import Console
from rich.prompt import Prompt
console = Console()
def remove(args):
if not "@" in args.name:
console.print(f"[b red]digpkg: failed to publish mineral: please include the version number in the package name. e.g: request@1.0.0")
sys.exit(1)
split_name = args.name.split("@")
mineral_name = split_name[0]
version = split_name[1]
# ask for user and pass
console.print("[b]Please authenticate.\n[/]")
try:
username = Prompt.ask("Username", console=console)
password = Prompt.ask("Password (or PAT)", console=console, password=True)
except KeyboardInterrupt:
return
console.print()
# send the request
response = requests.delete(
url=f"https://chookspace.com/api/packages/ground/generic/{mineral_name}/{version}",
auth=HTTPBasicAuth(username, password)
)
if response.status_code == 404:
console.print("[b red]digpkg: failed to remove mineral: mineral name or version was not found[/]")
sys.exit(1)
elif response.status_code == 401:
console.print("[b red]digpkg: failed to remove mineral: authentication failed[/]")
sys.exit(1)
response.raise_for_status()
console.print("[d][:white_check_mark:] Done![/]")

36
dig/uninstall.py Normal file
View File

@@ -0,0 +1,36 @@
import os, sys
import shutil
from .util import check_ground_libs_path, check_sudo
from rich.console import Console
console = Console()
def uninstall(args):
check_sudo()
check_ground_libs_path()
with console.status(status=f"Looking for [i]{args.name}[/]...", spinner="bouncingBall", spinner_style="green") as status:
mineral_path = os.path.join(os.getenv("GROUND_LIBS"), args.name)
symlink_path = os.path.join(os.getenv("GROUND_LIBS"), f"{args.name}.so")
# check to make sure the mineral is installed
if not os.path.isdir(mineral_path):
console.print(f"[b red]digpkg: failed to uninstall [i]{args.name}[/]: mineral is not installed[/b red]")
sys.exit(1)
# remove the symlink
status.update("Removing symlink...")
if os.path.islink(symlink_path):
os.unlink(symlink_path)
console.print(f"[d][:white_check_mark:] Removed symlink!")
# delete the folder
shutil.rmtree(mineral_path)
console.print(f"[d][:white_check_mark:] Removed mineral folder!")
console.print(f"[:white_check_mark:] Done!")

16
dig/util.py Normal file
View File

@@ -0,0 +1,16 @@
import os, sys
from rich import print
def check_ground_libs_path():
# ensure the GROUND_LIBS var is set
if not os.getenv("GROUND_LIBS"):
print("[d]digpkg: the [i]GROUND_LIBS[/] environment variable is not set, defaulting to /usr/lib/ground/")
os.environ["GROUND_LIBS"] = "/usr/lib/ground/"
def check_sudo():
# check if we are sudo
if os.getuid() != 0:
print("[b red]digpkg: that command requires sudo to run[/]")
sys.exit(1)