Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

FEATURE: Module Monitor #22

Merged
merged 17 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions .github/workflows/test-suite.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,24 @@ on:
push:
branches:
- dev
defaults:
run:
shell: bash
jobs:
test_suite:
name: Run test suite steps
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
environment: testing
steps:
- uses: actions/checkout@v3
- name: Set up Python 3.11.7
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v3
with:
python-version: "3.11.7"
python-version: ${{ matrix.python-version }}
- name: Install Poetry
uses: snok/install-poetry@v1
with:
Expand All @@ -43,7 +50,8 @@ jobs:
env:
DEBUG: True
run: |
poetry run pytest --cov --cov-report=lcov:cov.lcov --cov=startout
source $VENV
pytest --cov --cov-report=lcov:cov.lcov --cov=startout
- name: Coveralls GitHub Action
uses: coverallsapp/github-action@v2.2.3
with:
Expand Down
319 changes: 187 additions & 132 deletions poetry.lock

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
[tool.poetry]
name = "startout"
version = "0.1.0"
version = "0.3.3"
description = "Draft version of the StartOut CLI'"
authors = ["Your Name <you@example.com>"]
authors = ["Trenton Yo <askstartout@gmail.com>", "Jake Gehrke <askstartout@gmail.com>"]
readme = "README.md"

[tool.poetry.dependencies]
python = "^3.11"
python = "^3.8"
typer = "^0.12.3"
schema = "^0.7.7"
pyyaml = "^6.0.1"
pyyaml = "^6.0.2"
requests = "^2.32.3"
python-dotenv = "^1.0.1"
rich = "^13.7.1"
Expand All @@ -30,3 +30,4 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry.scripts]
startout-paths = "startout.paths:startout_paths_command"
startout-starter = "startout.paths:startout_starter_app"
122 changes: 89 additions & 33 deletions startout/module.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Dict, Tuple

from rich.console import Console
from schema import Schema, And, Or, Optional, Use

from startout.init_option import InitOption
from startout.util import replace_env, run_script_with_env_substitution, get_script
from startout.util import replace_env, run_script_with_env_substitution, get_script, MonitorOutput, \
monitored_subprocess, validate_str_list, is_yaml_loadable_type


def check_for_key(name: str, key: str, scripts: dict):
Expand All @@ -27,7 +31,8 @@ def check_for_key(name: str, key: str, scripts: dict):
missing_platforms.append(_platform)
if len(missing_platforms) > 0:
raise TypeError(
f"Script 'init' not fully defined for module \"{name}\" (missing {missing_platforms}). Failed to create Module.")
f"Script 'init' not fully defined for module \"{name}\" (missing {missing_platforms}). "
f"Failed to create Module.")


class Module:
Expand All @@ -44,7 +49,8 @@ class Module:

Attributes:
name (str): The name of the module.
dest (str): The destination of the module (usually used as the name of the directory into which the module is installed).
dest (str): The destination of the module (usually used as the name of the directory into which the module
is installed).
source (dict): The source of the module, given as any ONE of [git, curl, script, docker].
scripts (dict): Scripts associated with the module.
dependencies (str or list[str]): Dependencies of the module. (Optional)
Expand All @@ -54,19 +60,27 @@ class Module:
module_scripts_schema = Schema(
Or(
{
Optional(str): And(str),
Optional(str): str,
Optional("windows"): {
Optional(str): And(str)
Optional(str): str
},
Optional("mac"): {
Optional(str): And(str)
Optional(str): str
},
Optional("linux"): {
Optional(str): And(str)
Optional(str): str
},
},
)
)
module_init_options_schema = Schema(
{
"env_name": str,
Optional("type"): is_yaml_loadable_type,
"default": is_yaml_loadable_type,
"prompt": str,
}
)
module_schema = Schema(
{
"dest": And(str, Use(replace_env)),
Expand All @@ -76,19 +90,12 @@ class Module:
}
),
"scripts": module_scripts_schema,
Optional("depends_on"): Or(str, list[str]),
Optional("init_options"): list[Schema(
{
"env_name": And(str, len),
"type": And(str, len),
"default": And(str, len),
"prompt": And(str, len),
}
)]
Optional("depends_on"): Or(str, validate_str_list),
Optional("init_options"): [module_init_options_schema]
}
)

def __init__(self, name: str, dest: str, source: str, scripts: dict[str, str], dependencies=None,
def __init__(self, name: str, dest: str, source: str, scripts: Dict[str, str], dependencies=None,
init_options=None):
"""
Initialize a new Module instance.
Expand All @@ -100,10 +107,10 @@ def __init__(self, name: str, dest: str, source: str, scripts: dict[str, str], d
:param dependencies: (optional) A list of module names that this module depends on. Defaults to None.
:param init_options: (optional) Additional options for module initialization. Defaults to None.
"""
if get_script("init", scripts, name) is None:
raise TypeError(f"No 'init' script defined for module \"{name}\". Failed to create Module.")
if get_script("destroy", scripts, name) is None:
raise TypeError(f"No 'destroy' script defined for module \"{name}\". Failed to create Module.")

# These calls will raise an exception if the script is not present
get_script("init", scripts, name)
get_script("destroy", scripts, name)

self.name = name
self.dest = dest
Expand All @@ -129,31 +136,43 @@ def get_dest(self):
def get_source(self):
return replace_env(self.source)

def run(self, script: str, print_output: bool = False) -> tuple[str, int]:
def run(self, script: str, print_output: bool = False, monitor_output: MonitorOutput or None = None) -> Tuple[
str, int]:
"""
Runs a script with environment variable substitutions.

:param monitor_output:
:param script: The name of the script to be executed, located in the Module's scripts.
:param print_output: Whether to print the response at the .... level
:return: A tuple containing the stdout output and the return code of the script execution.
"""
if script not in self.scripts:
raise ValueError(f"Module \"{self.name}\" does not have script '{script}' in {list(self.scripts.keys())}")

response, code = run_script_with_env_substitution(get_script(script, self.scripts, self.get_name()))
response, code = run_script_with_env_substitution(get_script(script, self.scripts, self.get_name()),
monitor_output=monitor_output)

if print_output and type(response) is str and len(response.strip()) > 0:
print(f".... [{self.get_name()}.{script}]: {response.strip()}")

return response, code

def initialize(self):
def initialize(self, console: Console or None = None, log_path: Path or None = None):
"""
Run the Module's 'init' script.

:return: True if the response code is 0, False otherwise.
"""
msg, code = self.run("init", print_output=True)
mon = None
if console is not None and log_path is not None:
mon = MonitorOutput(
title=f"Initializing {self.get_name()}",
subtitle="...",
console=console,
log_path=log_path
)

msg, code = self.run("init", monitor_output=mon)

if code != 0:
print(f".. FAILURE [{self.get_name()}]: {msg}", file=sys.stderr)
Expand All @@ -162,13 +181,22 @@ def initialize(self):
print(f".. SUCCESS [{self.get_name()}]: Initialized module {self.get_name()}")
return True

def destroy(self):
def destroy(self, console: Console or None = None, log_path: Path or None = None):
"""
Run the Module's 'destroy' script.

:return: True if the response code is 0, False otherwise.
"""
msg, code = self.run("destroy", print_output=True)
mon = None
if console is not None and log_path is not None:
mon = MonitorOutput(
title=f"Initializing {self.get_name()}",
subtitle="...",
console=console,
log_path=log_path
)

msg, code = self.run("destroy", monitor_output=mon)

if code != 0:
print(f".. FAILURE [{self.get_name()}]: {msg}", file=sys.stderr)
Expand Down Expand Up @@ -203,7 +231,7 @@ class GitModule(Module):

"""

def initialize(self):
def initialize(self, console: Console or None = None, log_path: Path or None = None):
"""
Initializes the object by cloning `self.source` (a repository) to `self.dest` (a directory).

Expand All @@ -220,15 +248,33 @@ def initialize(self):
self.get_source(),
self.get_dest()
]
result = subprocess.run(cmd, text=True,
capture_output=True) # TODO hoist the feedback to the terminal, especially if it can be displayed by the CLI which enwraps it

if console is not None:
result = monitored_subprocess(
command=cmd,
title=f"Cloning {self.get_name()}",
subtitle="...",
console=console
)
else:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)

if result.returncode != 0:
print(f".. FAILURE [{self.get_name()}]: {result.stdout.strip()}", file=sys.stderr)
return False
else:
print(f".... PROGRESS [{self.get_name()}]: Cloned module {self.get_name()}, running init script")
msg, code = self.run("init", print_output=True)

mon = None
if console is not None and log_path is not None:
mon = MonitorOutput(
title=f"Initializing {self.get_name()}",
subtitle="...",
console=console,
log_path=log_path
)

msg, code = self.run("init", print_output=True, monitor_output=mon)

if code != 0:
print(f".. FAILURE [{self.get_name()}]: {msg}", file=sys.stderr)
Expand All @@ -239,7 +285,7 @@ def initialize(self):


class ScriptModule(Module):
def initialize(self):
def initialize(self, console: Console or None = None, log_path: Path or None = None):
msg, code = run_script_with_env_substitution(self.get_source())

if code != 0:
Expand All @@ -250,7 +296,17 @@ def initialize(self):
if type(msg) is str and len(msg.strip()) > 0:
print(f".... [{self.get_name()}.source.script]: {msg.strip()}")
print(f".... PROGRESS [{self.get_name()}]: Running init script")
msg, code = self.run("init", print_output=True)

mon = None
if console is not None and log_path is not None:
mon = MonitorOutput(
title=f"Initializing {self.get_name()}",
subtitle="...",
console=console,
log_path=log_path
)

msg, code = self.run("init", print_output=True, monitor_output=mon)

if code != 0:
print(f".. FAILURE [{self.get_name()}]: {msg}", file=sys.stderr)
Expand Down
Loading
Loading