tgzr.cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgzr/cli/__init__.py ADDED
File without changes
tgzr/cli/__main__.py ADDED
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+ if __name__ == "__main__":
4
+ # NB: we need a absolute imports here for pyinstaller to work
5
+ import tgzr.cli.main
6
+
7
+ tgzr.cli.main.main()
tgzr/cli/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.1.2'
32
+ __version_tuple__ = version_tuple = (0, 1, 2)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,109 @@
1
+ import os
2
+ import traceback
3
+ import importlib.metadata
4
+
5
+ import click
6
+
7
+
8
+ def add_plugins(group):
9
+
10
+ if not isinstance(group, click.Group):
11
+ raise TypeError(
12
+ f"plugins can only be attached to an instance of"
13
+ f" 'click.Group()' not: {repr(group)}"
14
+ )
15
+
16
+ entry_point_group = "tgzr.cli.plugin"
17
+
18
+ all_entry_points = importlib.metadata.entry_points(group=entry_point_group)
19
+
20
+ for ep in all_entry_points:
21
+ try:
22
+ installer = ep.load()
23
+ installer(group)
24
+ # group.add_command(ep.load())
25
+
26
+ # Catch all exceptions (technically not 'BaseException') and
27
+ # instead register a special 'BrokenCommand()'. Otherwise, a single
28
+ # plugin that fails to load and/or register will make the CLI
29
+ # inoperable. 'BrokenCommand()' explains the situation to users.
30
+ except Exception as e:
31
+ group.add_command(BrokenCommand(ep, e))
32
+
33
+ return group
34
+
35
+
36
+ class BrokenCommand(click.Command):
37
+ """Represents a plugin ``click.Command()`` that failed to load.
38
+
39
+ Can be executed just like a ``click.Command()``, but prints information
40
+ for debugging and exits with an error code.
41
+ """
42
+
43
+ def __init__(self, entry_point, exception):
44
+ """
45
+ :param importlib.metadata.EntryPoint entry_point:
46
+ Entry point that failed to load.
47
+ :param Exception exception:
48
+ Raised when attempting to load the entry point associated with
49
+ this instance.
50
+ """
51
+
52
+ super().__init__(entry_point.name)
53
+
54
+ # There are several ways to get a traceback from an exception, but
55
+ # 'TracebackException()' seems to be the most portable across actively
56
+ # supported versions of Python.
57
+ tbe = traceback.TracebackException.from_exception(exception)
58
+
59
+ # A message for '$ cli command --help'. Contains full traceback and a
60
+ # helpful note. The intention is to nudge users to figure out which
61
+ # project should get a bug report since users are likely to report the
62
+ # issue to the developers of the CLI utility they are directly
63
+ # interacting with. These are not necessarily the right developers.
64
+ self.help = (
65
+ "{ls}ERROR: entry point '{module}:{name}' could not be loaded."
66
+ " Contact its author for help.{ls}{ls}{tb}"
67
+ ).format(
68
+ module=entry_point.module,
69
+ name=entry_point.name,
70
+ ls=os.linesep,
71
+ tb="".join(tbe.format()),
72
+ )
73
+
74
+ # Replace the broken command's summary with a warning about how it
75
+ # was not loaded successfully. The idea is that '$ cli --help' should
76
+ # include a clear indicator that a subcommand is not functional, and
77
+ # a little hint for what to do about it. U+2020 is a "dagger", whose
78
+ # modern use typically indicates a footnote.
79
+ self.short_help = (
80
+ f"\u2020 Warning: could not load plugin. Invoke command with"
81
+ f" '--help' for traceback."
82
+ )
83
+
84
+ def invoke(self, ctx):
85
+ """Print traceback and debugging message.
86
+
87
+ :param click.Context ctx:
88
+ Active context.
89
+ """
90
+
91
+ click.echo(self.help, color=ctx.color, err=True)
92
+ ctx.exit(1)
93
+
94
+ def parse_args(self, ctx, args):
95
+ """Pass arguments along without parsing.
96
+
97
+ :param click.Context ctx:
98
+ Active context.
99
+ :param list args:
100
+ List of command line arguments.
101
+ """
102
+
103
+ # Do not attempt to parse these arguments. We do not know why the
104
+ # entry point failed to load, but it is reasonable to assume that
105
+ # argument parsing will not work. Ultimately the goal is to get the
106
+ # 'Command.invoke()' method (overloaded in this class) to execute
107
+ # and provide the user with a bit of debugging information.
108
+
109
+ return args
tgzr/cli/install.py ADDED
@@ -0,0 +1,147 @@
1
+ from typing import Callable
2
+
3
+ import os
4
+ import sys
5
+ from pathlib import Path
6
+ import tempfile
7
+ import platform
8
+
9
+ import uv
10
+
11
+
12
+ def install(
13
+ home: Path | str,
14
+ studio_name: str,
15
+ python_version: str | None = "3.12",
16
+ default_index: str | None = None,
17
+ find_links: str | None = None,
18
+ allow_prerelease: bool = False,
19
+ echo: Callable[[str], None] | None = None,
20
+ ):
21
+ """
22
+ Install TGZR by creating a tmp venv with tgzr.shell, then
23
+ using this tgzr.cli to create a Studio at the requested location.
24
+
25
+ May raise: FileExistsError, ChildProcessError.
26
+ """
27
+ if echo is None:
28
+ echo = lambda message: print(message)
29
+
30
+ home = Path(home)
31
+ studio_path = home / "Workspace" / studio_name
32
+ if studio_path.exists():
33
+ raise FileExistsError(f"The Studio {studio_path} already exists. Aborting.")
34
+
35
+ echo(f"Installing tgzr at {home}, creating Studio {studio_name}")
36
+
37
+ # We need to create the `.tgzr` file at requested home first, or
38
+ # the cli may discover existing locations and create the Studio
39
+ # in an existing installation:
40
+ home.mkdir(exist_ok=True, parents=True)
41
+ (home / ".tgzr").touch()
42
+
43
+ venv_path = tempfile.mkdtemp(prefix="tgzr_install_tmp_venv")
44
+ echo(f"Creating temp venv: {venv_path}")
45
+
46
+ # Clean up PATH
47
+ # (we've seen situations where things in the PATH would mess up the installation )
48
+ PATH = os.environ.get("PATH", "")
49
+ path = PATH.split(os.pathsep)
50
+ banned_words = ["python", ".poetry"]
51
+ clean_path = []
52
+ for i in path:
53
+ keep = True
54
+ for word in banned_words:
55
+ if word in i.lower():
56
+ keep = False
57
+ break
58
+ if keep:
59
+ clean_path.append(i)
60
+ os.environ["PATH"] = os.pathsep.join(clean_path)
61
+
62
+ if 0:
63
+ # This does not work as pyinstaller script: sys.executable is wrong so venv is messed up
64
+ print(sys.executable)
65
+ cmd = f"{sys.executable} -m uv venv --prompt TGZR_installer {venv_path}"
66
+ echo(f"EXEC: {cmd}")
67
+ ret = os.system(cmd)
68
+ print("--->", ret)
69
+ elif 0:
70
+ # This does not work as pyinstaller script: sys.executable is wrong so venv is messed up
71
+ import venv
72
+
73
+ try:
74
+ venv.main(["--without-pip", "--prompt", "TGZR-Installer", venv_path])
75
+ except Exception:
76
+ raise
77
+ else:
78
+ # This does work as pyinstaller script: we are delegating everything to uv
79
+
80
+ # Use this to inspect the content of the pyinstaller archive when
81
+ # we run as a pysintaller script:
82
+ # if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
83
+ # ROOT = sys._MEIPASS
84
+ # print("FROZEN CONTENT:", os.listdir(ROOT))
85
+
86
+ try:
87
+ # NOTE: this also works when we're a pysintaller script thanks to the
88
+ # data arg of the Analysis in the pyinstall spec file:
89
+ # it keeps the uv executable installed in your current venv
90
+ # (it exists because we have uv in the project requirement)
91
+ # and place it in a "bin" folder inside the pysintaller archive.
92
+ # This bin folder is looked up by uv.find_uv_bin() so we're
93
+ # good.
94
+ uv_exe = uv.find_uv_bin()
95
+ except Exception as err:
96
+ # This should not occur ¯\\_(ツ)_/¯
97
+ echo(f"Oops, could not find uv: {err}")
98
+ else:
99
+ cmd = (
100
+ f"{uv_exe} venv -p {python_version} --prompt TGZR-Installer {venv_path}"
101
+ )
102
+ echo(f"EXEC: {cmd}")
103
+ ret = os.system(cmd)
104
+ if ret:
105
+ raise Exception("Error creating venv with cmd: {cmd}")
106
+
107
+ default_index_options = ""
108
+ if default_index:
109
+ default_index_options = f"--default-index {default_index}"
110
+
111
+ find_links_options = ""
112
+ if find_links:
113
+ find_links_options = f"--find-links {find_links}"
114
+
115
+ prerelease_options = ""
116
+ if allow_prerelease:
117
+ prerelease_options = "--prerelease=allow"
118
+
119
+ # Install tgzr.shell in the temp venv:
120
+ cmd = f"{uv_exe} pip install {default_index_options} {find_links_options} {prerelease_options} --python {venv_path} tgzr.shell"
121
+ echo(f"EXEC: {cmd}")
122
+ ret = os.system(cmd)
123
+ if ret:
124
+ raise ChildProcessError("Error installing packages in venv with cmd: {cmd}")
125
+
126
+ # Use tgzr.cli from the temp venv to create the Studio
127
+ # ! Don't forget to pass the index related options there !
128
+ index_options = ""
129
+ if default_index:
130
+ index_options += f" --default-index {default_index}"
131
+ if find_links:
132
+ index_options += f" --find-links {find_links}"
133
+ if allow_prerelease:
134
+ index_options += " --allow-prerelease"
135
+ if 0:
136
+ # IDKW but this does not work, it runs the orignal tgzr cmd instead of the venv one :[
137
+ cmd = f"{uv_exe} run --python {venv_path} tgzr --home {home} studio create {index_options} {studio_name}"
138
+ else:
139
+ if platform.system() == "Windows":
140
+ tgzr_exe = f"{venv_path}/Scripts/tgzr"
141
+ else:
142
+ tgzr_exe = f"{venv_path}/bin/tgzr"
143
+ cmd = f"{tgzr_exe} --home {home} studio create {index_options} {studio_name}"
144
+ ret = os.system(cmd)
145
+ echo(f"EXEC: {cmd}")
146
+ if ret:
147
+ raise ChildProcessError(f"Error creating Studio with cmd: {cmd}")
@@ -0,0 +1,143 @@
1
+ from pathlib import Path
2
+
3
+ import click
4
+
5
+ from ._version import __version__
6
+ from .install import install
7
+
8
+
9
+ @click.command("install")
10
+ @click.option(
11
+ "-S",
12
+ "--studio",
13
+ default=None,
14
+ help="Name of the Studio to create.",
15
+ )
16
+ @click.option(
17
+ "-H",
18
+ "--home",
19
+ help="Path to install to. Default to current dir. Created if needed.",
20
+ )
21
+ @click.option(
22
+ "-p",
23
+ "--python-version",
24
+ default="3.12",
25
+ help="The python version to use. Defaults to 3.12 which in the minmum (3.14 is know to cause issue with nicegui on windows)",
26
+ )
27
+ @click.option(
28
+ "--default-index",
29
+ help="The URL of the default package index (by default: <https://pypi.org/simple>).",
30
+ )
31
+ @click.option(
32
+ "-f",
33
+ "--find-links",
34
+ help="path a folder containing packages to install. Usefull in no-internet situations.",
35
+ )
36
+ @click.option(
37
+ "--allow-prerelease",
38
+ is_flag=True,
39
+ help="Allow installing tgzr using pre-release packages. Default is False.",
40
+ )
41
+ @click.option(
42
+ "-i",
43
+ "--info",
44
+ is_flag=True,
45
+ help="Show info about this installer.",
46
+ )
47
+ def install_cmd(
48
+ studio: str | None,
49
+ home: str | None,
50
+ python_version,
51
+ default_index,
52
+ find_links,
53
+ allow_prerelease,
54
+ info,
55
+ ):
56
+ """
57
+ Install a new tgzr environment.
58
+ """
59
+ if info:
60
+ import tgzr.cli
61
+ import sys
62
+ import uv
63
+
64
+ click.echo("TGZR Installer:")
65
+ click.echo(f"{tgzr.cli._version.__version__=}")
66
+ click.echo(f"{sys.version=}")
67
+ click.echo(f"{sys.executable=}")
68
+ click.echo(f"{uv.find_uv_bin()=}")
69
+ return
70
+
71
+ if studio is None:
72
+ studio = str(click.prompt(f"Studio name", default="MyStudio"))
73
+ studio = studio.strip().replace(" ", "_") # overall better + avoids code injection
74
+
75
+ if home is None:
76
+ cwd = Path.cwd().resolve()
77
+ home = str(
78
+ click.prompt(f"Install path (can be relative to current dir)", default=cwd)
79
+ )
80
+ home = home.strip()
81
+ if not Path(home).is_absolute():
82
+ home = str((cwd / home).resolve())
83
+
84
+ try:
85
+ install(
86
+ home,
87
+ studio,
88
+ python_version,
89
+ default_index,
90
+ find_links,
91
+ allow_prerelease,
92
+ click.echo,
93
+ )
94
+ except (FileExistsError, ChildProcessError) as err:
95
+ click.echo(f"\nInstallation failed: {err}\nPlease contact your adminitrator.")
96
+ return
97
+
98
+ # Some shell won't allow unicodes :/ so:
99
+ try:
100
+ click.echo("\n\n✨ tgzr successfully installed ✨")
101
+ except:
102
+ click.echo("\n\n tgzr successfully installed!")
103
+
104
+
105
+ @click.command("install")
106
+ def install_help():
107
+ click.echo("# Installing TGZR with `tgzr install`")
108
+ click.echo(
109
+ """
110
+ TGZR installs itself in a folder called "home".
111
+ This folder will contain:
112
+ - System : a folder with technical bits managed by TGZR.
113
+ - Workspace: a folder for everything work related.
114
+ - `.tgzr` : a configuration file for the installation.
115
+
116
+ Everything you will do with TGZR will be related to a Studio.
117
+ Your Studios are located in the Workspace folder.
118
+
119
+ When installing TGZR, you need to know:
120
+ - Where you want your "home" to be
121
+ - The name of you studio
122
+
123
+ The easiest way to install TGZR with the command line
124
+ is to go into the folder you chose as "home" and enter:
125
+ `tgzr install`
126
+ You will be prompter for a Studio name, and you will
127
+ be prompter for an install path with a default value of the
128
+ current folder.
129
+
130
+ You can also specify the home path and the studio name
131
+ with arguments:
132
+ `tgzr install --home /path/to/tgzr/home --studio MyStudioName`
133
+
134
+ # Advanced
135
+
136
+ During installation, tgzr will fetch packages from PyPI.
137
+ If you need to use custom packages instead of official ones,
138
+ you can override the default package index with
139
+ `--default-index` and/or specify an local folder containing
140
+ packages with `--find-links`.
141
+
142
+ """
143
+ )
tgzr/cli/main.py ADDED
@@ -0,0 +1,7 @@
1
+ import sys
2
+
3
+
4
+ def main():
5
+ from tgzr.cli.main_cli import tgzr_cli
6
+
7
+ sys.exit(tgzr_cli())
tgzr/cli/main_cli.py ADDED
@@ -0,0 +1,31 @@
1
+ import click
2
+
3
+
4
+ from ._version import __version__
5
+ from .add_plugins import add_plugins
6
+ from .install_cli import install_cmd, install_help
7
+
8
+ from .utils import TGZRCliGroup
9
+
10
+
11
+ @click.group(
12
+ cls=TGZRCliGroup,
13
+ context_settings={"help_option_names": ["-h", "--help"]},
14
+ )
15
+ @click.version_option(version=__version__, prog_name="tgzr")
16
+ def tgzr_cli(
17
+ **kwargs, # needed for plugin installing custom options
18
+ ):
19
+ pass
20
+
21
+
22
+ @tgzr_cli.group(cls=TGZRCliGroup, help="Documentations and tooltips.")
23
+ def help():
24
+ pass
25
+
26
+
27
+ tgzr_cli.add_command(install_cmd)
28
+ help.add_command(install_help)
29
+ tgzr_cli.set_default_command(install_cmd)
30
+
31
+ add_plugins(tgzr_cli)
tgzr/cli/utils.py ADDED
@@ -0,0 +1,99 @@
1
+ import inspect
2
+
3
+ import click
4
+
5
+
6
+ class TGZRCliGroup(click.Group):
7
+ """
8
+ This Group recognizes commands with less
9
+ than their full name when there is no ambiguity.
10
+ For example: 'wo'->'workspace' if no other command
11
+ starts with 'wo'
12
+
13
+ This Group can be configured with a default command.
14
+ This command will be invoked if no command is provided.
15
+
16
+ This Group can find a sub-group for you with
17
+ `find_group(group_name)`, which may be used by cli plugins
18
+ to install their commands and groups where they want
19
+ to.
20
+ """
21
+
22
+ def __init__(self, *args, **kwargs) -> None:
23
+ kwargs["result_callback"] = self.__result_callback
24
+ super().__init__(*args, **kwargs)
25
+ self.no_args_is_help = True
26
+ self.invoke_without_command = False
27
+ self._looked_up_cmds: list[str] = []
28
+
29
+ self._default_command: click.Command | None = None
30
+ self._default_command_kwargs: dict | None = None
31
+ self._default_command_setter_module: str | None = None
32
+
33
+ def set_default_command(self, cmd: click.Command | None, **kwargs) -> None:
34
+ frame: inspect.FrameInfo = inspect.stack()[1]
35
+ setter_module = inspect.getmodule(frame[0])
36
+ self._default_command_setter_module = (
37
+ setter_module and setter_module.__name__ or None
38
+ )
39
+
40
+ if cmd is None:
41
+ self.no_args_is_help = True
42
+ self.invoke_without_command = False
43
+ self._default_command = None
44
+ self._default_command_kwargs = None
45
+ return
46
+ else:
47
+ self.no_args_is_help = False
48
+ self.invoke_without_command = True
49
+ self._default_command = cmd
50
+ self._default_command_kwargs = kwargs
51
+
52
+ def get_default_command(
53
+ self,
54
+ ) -> tuple[click.Command | None, dict | None, str | None]:
55
+ return (
56
+ self._default_command,
57
+ self._default_command_kwargs,
58
+ self._default_command_setter_module,
59
+ )
60
+
61
+ def get_command(self, ctx: click.Context, cmd_name: str) -> click.Command | None:
62
+ self._looked_up_cmds.append(cmd_name)
63
+ known_commands = self.list_commands(ctx)
64
+ if cmd_name not in known_commands:
65
+ found = [name for name in known_commands if name.startswith(cmd_name)]
66
+ if len(found) > 1:
67
+ raise click.UsageError(
68
+ f'Ambiuous command "{cmd_name}" (could be {' or '.join(found)}).'
69
+ )
70
+ elif found:
71
+ cmd_name = found[0]
72
+
73
+ return super().get_command(ctx, cmd_name)
74
+
75
+ def find_group(self, named: str) -> click.Group | None:
76
+ """
77
+ Find a click group under this group.
78
+ Usefull to install plugin under sub commands.
79
+ """
80
+ for name, value in self.commands.items():
81
+ if not isinstance(value, click.Group):
82
+ continue
83
+ if name == named:
84
+ return value
85
+ return None
86
+
87
+ @click.pass_context
88
+ @staticmethod
89
+ def __result_callback(ctx: click.Context, self, *result, **kwargs):
90
+ # See that "self" arg on a staticmethod ? Don't ask!!!! :p
91
+
92
+ if self._looked_up_cmds:
93
+ # Only invoke default command when no other command
94
+ # was specified. We use self._looked_up_cmds to
95
+ # find out if a command was given.
96
+ return
97
+
98
+ if self._default_command is not None:
99
+ ctx.invoke(self._default_command, **self._default_command_kwargs)