Skip to content

Commit

Permalink
Merge pull request #30 from DavidCEllis/cli_improvements
Browse files Browse the repository at this point in the history
CLI Improvements - Bugfix for bundles
  • Loading branch information
DavidCEllis authored Oct 31, 2024
2 parents aea6e44 + 5869ac6 commit 9719574
Show file tree
Hide file tree
Showing 12 changed files with 97 additions and 45 deletions.
6 changes: 5 additions & 1 deletion src/ducktools/env/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,18 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

from ._version import __version__, __version_tuple__
from ._version import (
__version__ as __version__,
__version_tuple__ as __version_tuple__
)


MINIMUM_PYTHON = (3, 10)
MINIMUM_PYTHON_STR = ".".join(str(v) for v in MINIMUM_PYTHON)


PROJECT_NAME = "ducktools"
APP_COMMAND = "ducktools-env"

FOLDER_ENVVAR = "DUCKTOOLS_ENV_FOLDER"
LAUNCH_TYPE_ENVVAR = "DUCKTOOLS_ENV_LAUNCH_TYPE"
Expand Down
31 changes: 23 additions & 8 deletions src/ducktools/env/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,9 @@ def _get_formatter(self):
return self.formatter_class(prog=self.prog, width=columns-2)


def get_parser(exit_on_error=True) -> FixedArgumentParser:
def get_parser(prog, exit_on_error=True) -> FixedArgumentParser:
parser = FixedArgumentParser(
prog="ducktools-env",
prog=prog,
description="Script runner and bundler for scripts with inline dependencies",
exit_on_error=exit_on_error,
)
Expand Down Expand Up @@ -285,7 +285,12 @@ def get_columns(


def main():
parser = get_parser()
if __name__ == "__main__":
command = f"{os.path.basename(sys.executable)} -m ducktools.env"
else:
command = os.path.basename(sys.argv[0])

parser = get_parser(prog=command)
args, unknown = parser.parse_known_args()

if unknown:
Expand All @@ -303,7 +308,10 @@ def main():
parser.error(f"unrecognised arguments: {unknown_s}")

# Create a manager
manager = _laz.Manager(PROJECT_NAME)
manager = _laz.Manager(
project_name=PROJECT_NAME,
command=command,
)

if args.command == "run":
# Split on existence of the command as a file, if the file exists run it
Expand All @@ -324,14 +332,13 @@ def main():
)

elif args.command == "bundle":
bundle_path = manager.create_bundle(
manager.create_bundle(
script_path=args.script_filename,
with_lock=args.with_lock,
generate_lock=args.generate_lock,
output_file=args.output,
compressed=args.compressed,
compressed=args.compress,
)
print(f"Bundle created at '{bundle_path}'")

elif args.command == "register":
if args.remove:
Expand Down Expand Up @@ -432,4 +439,12 @@ def main():


if __name__ == "__main__":
main()
try:
main()
except RuntimeError as e:
errors = "\n".join(e.args) + "\n"
if sys.stderr:
sys.stderr.write(errors)
sys.exit(1)

sys.exit(0)
38 changes: 26 additions & 12 deletions src/ducktools/env/_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,15 +41,29 @@ def run():
# First argument is the path to this script
_, app, *args = sys.argv

manager = Manager(PROJECT_NAME)

if os.path.isfile(app):
manager.run_script(
script_path=app,
script_args=args,
)
else:
manager.run_registered_script(
script_name=app,
script_args=args,
)
# This has been invoked by dtrun, but errors should show ducktools-env
command = "ducktools-env"

manager = Manager(
project_name=PROJECT_NAME,
command=command,
)

try:
if os.path.isfile(app):
manager.run_script(
script_path=app,
script_args=args,
)
else:
manager.run_registered_script(
script_name=app,
script_args=args,
)
except RuntimeError as e:
msg = "\n".join(e.args) + "\n"
if sys.stderr:
sys.stderr.write(msg)
sys.exit(1)

sys.exit(0)
3 changes: 1 addition & 2 deletions src/ducktools/env/bootstrapping/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ def launch_script(script_file, zipapp_path, args, lockdata=None):
sys.path.insert(0, default_paths.env_folder)
try:
from ducktools.env.manager import Manager
from ducktools.env.environment_specs import EnvironmentSpec
manager = Manager(PROJECT_NAME)
manager = Manager(project_name=PROJECT_NAME)
manager.run_bundle(
script_path=script_file,
script_args=args,
Expand Down
4 changes: 4 additions & 0 deletions src/ducktools/env/bootstrapping/bundle_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.

# Suppress ruff error for imports not at the top of the file,
# version check needs to come first.
# ruff: noqa: E402

# This becomes the bundler bootstrap python script
import sys

Expand Down
2 changes: 1 addition & 1 deletion src/ducktools/env/catalogue.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import os.path
from datetime import datetime as _datetime, timedelta as _timedelta

from ducktools.classbuilder.prefab import prefab, attribute
from ducktools.classbuilder.prefab import prefab

from ._sqlclasses import SQLAttribute, SQLClass, SQLContext
from .exceptions import InvalidEnvironmentSpec, VenvBuildError, ApplicationError
Expand Down
12 changes: 6 additions & 6 deletions src/ducktools/env/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ class InvalidBundleScript(EnvError):
pass


class ScriptNotFound(EnvError):
"""
Error if a registered script is not found
"""


class ApplicationError(EnvError):
"""
Error if an outdated application attempted to run
Expand All @@ -53,9 +59,3 @@ class InvalidPipDownload(EnvError):
Error if the hash value of the downloaded `pip` does not match
the value this application has for that version.
"""


class UVUnavailableError(EnvError):
"""
Error if UV is not available and can't be installed.
"""
31 changes: 22 additions & 9 deletions src/ducktools/env/manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
from . import (
FOLDER_ENVVAR,
PROJECT_NAME,
APP_COMMAND,
DATA_BUNDLE_ENVVAR,
DATA_BUNDLE_FOLDER,
LAUNCH_ENVIRONMENT_ENVVAR,
Expand All @@ -40,7 +41,11 @@
from .platform_paths import ManagedPaths
from .catalogue import TemporaryCatalogue, ApplicationCatalogue
from .environment_specs import EnvironmentSpec
from .exceptions import UVUnavailableError, InvalidEnvironmentSpec, PythonVersionNotFound
from .exceptions import (
InvalidEnvironmentSpec,
PythonVersionNotFound,
ScriptNotFound,
)
from .register import RegisterManager, RegisteredScript

from ._lazy_imports import laz as _laz
Expand All @@ -50,15 +55,17 @@
class Manager(Prefab):
project_name: str = PROJECT_NAME
config: Config = None
command: str | None = None

paths: ManagedPaths = attribute(init=False, repr=False)
_temp_catalogue: TemporaryCatalogue | None = attribute(default=None, private=True)
_app_catalogue: ApplicationCatalogue | None = attribute(default=None, private=True)
_script_registry: RegisterManager | None = attribute(default=None, private=True)

def __prefab_post_init__(self, config):
def __prefab_post_init__(self, config, command):
self.paths = ManagedPaths(self.project_name)
self.config = Config.load(self.paths.config_path) if config is None else config
self.command = command if command else APP_COMMAND

@property
def temp_catalogue(self) -> TemporaryCatalogue:
Expand Down Expand Up @@ -112,8 +119,8 @@ def retrieve_uv(self, required=False) -> str | None:
uv_path = None

if uv_path is None and required:
raise UVUnavailableError(
"UV is required for this process but is unavailable"
raise RuntimeError(
"UV is required for this process but is unavailable."
)

return uv_path
Expand Down Expand Up @@ -397,7 +404,7 @@ def create_bundle(
generate_lock: bool = False,
output_file: str | None = None,
compressed: bool = False,
) -> str:
) -> None:
"""
Create a zipapp bundle for the provided spec
Expand All @@ -406,7 +413,6 @@ def create_bundle(
:param generate_lock: Generate a lockfile when bundling
:param output_file: output path to zipapp bundle (script_file.pyz default)
:param compressed: Compress the resulting zipapp
:return: Path to the output bundle
"""
if not self.is_installed or self.install_outdated:
self.install()
Expand All @@ -425,8 +431,6 @@ def create_bundle(
compressed=compressed,
)

return output_file

def generate_lockfile(
self,
script_path: str,
Expand Down Expand Up @@ -473,7 +477,16 @@ def run_registered_script(
generate_lock: bool = False,
lock_path: str | None = None,
) -> None:
row = self.script_registry.retrieve_script(script_name=script_name)
try:
row = self.script_registry.retrieve_script(script_name=script_name)
except ScriptNotFound as e:
raise RuntimeError(
"\n".join(e.args),
f"Use '{self.command} list --scripts' to show registered scripts",
)
except FileNotFoundError as e:
raise RuntimeError(e.args)

script_path = row.path

self.run_script(
Expand Down
6 changes: 3 additions & 3 deletions src/ducktools/env/register.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

from ._lazy_imports import laz as _laz
from ._sqlclasses import SQLAttribute, SQLClass, SQLContext
from .exceptions import ScriptNotFound


class RegisteredScript(SQLClass):
Expand Down Expand Up @@ -83,9 +84,8 @@ def retrieve_script(self, script_name: str) -> RegisteredScript:
row = RegisteredScript.select_row(con, row_filter)

if row is None:
raise RuntimeError(
f"'{script_name}' is not a registered script. "
f"Use `python -m ducktools.env list --scripts` to show registered scripts."
raise ScriptNotFound(
f"'{script_name}' is not a registered script."
)

if not os.path.exists(row.path):
Expand Down
2 changes: 1 addition & 1 deletion src/ducktools/env/scripts/get_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ def retrieve_uv(paths: ManagedPaths, reinstall: bool = False) -> str | None:
check=True,
)
except _laz.subprocess.CalledProcessError as e:
log("UV download failed: {e}")
log(f"UV download failed: {e}")
uv_path = None
else:
# Copy the executable out of the pip install
Expand Down
4 changes: 3 additions & 1 deletion tests/test_get_uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ def test_uv_install_failure(self):
exists_mock.return_value = False

run_mock.side_effect = subprocess.CalledProcessError(
sys.executable, "Could not run PIP"
returncode=1,
cmd=sys.executable,
stderr="Could not run PIP"
)

uv_path = get_uv.retrieve_uv(paths=self.paths, reinstall=False)
Expand Down
3 changes: 2 additions & 1 deletion tests/test_register.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@

from ducktools.env._sqlclasses import SQLClass
from ducktools.env.register import RegisteredScript, RegisterManager
from ducktools.env.exceptions import ScriptNotFound


@pytest.fixture(scope="function")
Expand Down Expand Up @@ -84,7 +85,7 @@ def test_add_remove_retrieve_script(self, test_register):

# Delete the row and confirm it is no longer retrievable
test_register.remove_script(script_name)
with pytest.raises(RuntimeError):
with pytest.raises(ScriptNotFound):
test_register.retrieve_script(script_name)

def test_add_script_not_found(self, test_register):
Expand Down

0 comments on commit 9719574

Please sign in to comment.