Added cpl-cli
This commit is contained in:
21
src/cpl-cli/cpl.project.json
Normal file
21
src/cpl-cli/cpl.project.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "cpl-cli",
|
||||
"version": "0.1.0",
|
||||
"type": "console",
|
||||
"license": "MIT",
|
||||
"author": "Sven Heidemann",
|
||||
"description": "CLI for the CPL library",
|
||||
"homepage": "",
|
||||
"keywords": [],
|
||||
"dependencies": {
|
||||
"click": "~8.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"black": "25.1.0"
|
||||
},
|
||||
"references": [
|
||||
"../cpl/cpl.project.json"
|
||||
],
|
||||
"main": "cpl/cli/main.py",
|
||||
"directory": "cpl/cli"
|
||||
}
|
||||
0
src/cpl-cli/cpl/cli/__init__.py
Normal file
0
src/cpl-cli/cpl/cli/__init__.py
Normal file
33
src/cpl-cli/cpl/cli/cli.py
Normal file
33
src/cpl-cli/cpl/cli/cli.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import click
|
||||
|
||||
|
||||
class AliasedGroup(click.Group):
|
||||
def command(self, *args, **kwargs):
|
||||
aliases = kwargs.pop("aliases", [])
|
||||
|
||||
def decorator(f):
|
||||
cmd = super(AliasedGroup, self).command(*args, **kwargs)(f)
|
||||
|
||||
for alias in aliases:
|
||||
self.add_command(cmd, alias)
|
||||
return cmd
|
||||
|
||||
return decorator
|
||||
|
||||
def format_commands(self, ctx, formatter):
|
||||
commands = []
|
||||
seen = set()
|
||||
for name, cmd in self.commands.items():
|
||||
if cmd in seen:
|
||||
continue
|
||||
seen.add(cmd)
|
||||
aliases = [a for a, c in self.commands.items() if c is cmd and a != name]
|
||||
alias_text = f" (aliases: {', '.join(aliases)})" if aliases else ""
|
||||
commands.append((name, f"{cmd.short_help or ''}{alias_text}"))
|
||||
|
||||
with formatter.section("Commands"):
|
||||
formatter.write_dl(commands)
|
||||
|
||||
|
||||
@click.group(cls=AliasedGroup)
|
||||
def cli(): ...
|
||||
0
src/cpl-cli/cpl/cli/command/__init__.py
Normal file
0
src/cpl-cli/cpl/cli/command/__init__.py
Normal file
86
src/cpl-cli/cpl/cli/command/init.py
Normal file
86
src/cpl-cli/cpl/cli/command/init.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cpl.cli.const import PROJECT_TYPES, PROJECT_TYPES_SHORT
|
||||
from cpl.cli.model.project import Project
|
||||
from cpl.cli.model.workspace import Workspace
|
||||
from cpl.cli.utils.prompt import SmartChoice
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
@click.command("init")
|
||||
@click.argument("target", required=False)
|
||||
@click.argument("name", required=False)
|
||||
def init(target: str, name: str):
|
||||
if target is None:
|
||||
Console.write_line("CPL Init Wizard")
|
||||
target = click.prompt(
|
||||
"What do you want to initialize?",
|
||||
type=SmartChoice(["workspace"] + PROJECT_TYPES, {"workspace": "ws"}),
|
||||
show_choices=True,
|
||||
)
|
||||
|
||||
if target in PROJECT_TYPES_SHORT:
|
||||
target = [pt for pt in PROJECT_TYPES if pt.startswith(target)][0]
|
||||
|
||||
if target in ["workspace", "ws"]:
|
||||
_init_workspace(name or click.prompt("Workspace name", default="my-workspace"))
|
||||
elif target in PROJECT_TYPES:
|
||||
_init_project(name or click.prompt("Project name", default=f"my-{target}"), target)
|
||||
else:
|
||||
Console.error(f"Unknown target '{target}'")
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
def _init_workspace(name: str):
|
||||
path = Path("cpl.workspace.json")
|
||||
|
||||
if path.exists():
|
||||
Console.write_line("workspace.json already exists.")
|
||||
return
|
||||
|
||||
data = {
|
||||
"name": name,
|
||||
"projects": [],
|
||||
"defaultProject": None,
|
||||
"scripts": {},
|
||||
}
|
||||
workspace = Workspace.new("./", name)
|
||||
workspace.save()
|
||||
|
||||
Console.write_line(f"Created workspace '{name}'")
|
||||
|
||||
|
||||
def _init_project(name: str, project_type: str):
|
||||
project = Project.new("./", name, project_type)
|
||||
|
||||
path = Path("cpl.project.json")
|
||||
if path.exists():
|
||||
Console.write_line("cpl.project.json already exists.")
|
||||
return
|
||||
|
||||
if not Path("src").exists():
|
||||
project.directory = click.prompt(
|
||||
"Project directory", type=click.Path(exists=True, file_okay=False), default="src"
|
||||
)
|
||||
|
||||
if project_type in ["console", "web", "service"]:
|
||||
project.main = click.prompt(
|
||||
"Main executable", type=click.Path(exists=True, dir_okay=False), default="src/main.py"
|
||||
)
|
||||
|
||||
project.save()
|
||||
|
||||
workspace_file = Path("../cpl.workspace.json")
|
||||
if workspace_file.exists():
|
||||
workspace = Workspace.from_file(workspace_file)
|
||||
|
||||
rel_path = str(Path.cwd().relative_to(workspace_file.parent))
|
||||
if rel_path not in workspace.projects:
|
||||
workspace.projects.append(rel_path)
|
||||
workspace.save()
|
||||
|
||||
Console.write_line(f"Registered '{name}' in workspace.json")
|
||||
|
||||
Console.write_line(f"Created {project_type} project '{name}'")
|
||||
41
src/cpl-cli/cpl/cli/command/install.py
Normal file
41
src/cpl-cli/cpl/cli/command/install.py
Normal file
@@ -0,0 +1,41 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cpl.cli.cli import cli
|
||||
from cpl.cli.utils.pip import Pip
|
||||
from cpl.cli.utils.structure import resolve_project
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
@cli.command("install", aliases=["i"])
|
||||
@click.option(
|
||||
"--path",
|
||||
"project_path",
|
||||
type=click.Path(file_okay=False, exists=False),
|
||||
default=".",
|
||||
help="Path to project directory",
|
||||
)
|
||||
@click.option("--name", "project_name", help="Project name (lookup via workspace)")
|
||||
@click.option("--dev", is_flag=True, help="Include dev dependencies")
|
||||
@click.option("--verbose", is_flag=True, help="Enable verbose output")
|
||||
def install(project_path: str, project_name: str, dev: bool, verbose: bool):
|
||||
project = resolve_project(Path(project_path), project_name)
|
||||
deps: dict = project.dependencies
|
||||
if dev:
|
||||
deps.update(project.dev_dependencies)
|
||||
|
||||
if not deps:
|
||||
Console.error("No dependencies to install.")
|
||||
return
|
||||
|
||||
Console.write_line(f"Installing dependencies for '{project.name}':")
|
||||
|
||||
for name, version in deps.items():
|
||||
dep = Pip.normalize_dep(name, version)
|
||||
Console.write_line(f" -> {dep}")
|
||||
try:
|
||||
Pip.command(project_path, "install", dep, verbose=verbose)
|
||||
except subprocess.CalledProcessError as e:
|
||||
Console.error(f"Failed to install {dep}: exit code {e.returncode}")
|
||||
39
src/cpl-cli/cpl/cli/command/uninstall.py
Normal file
39
src/cpl-cli/cpl/cli/command/uninstall.py
Normal file
@@ -0,0 +1,39 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
|
||||
from cpl.cli.cli import cli
|
||||
from cpl.cli.utils.pip import Pip
|
||||
from cpl.cli.utils.structure import resolve_project
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
@cli.command("uninstall", aliases=["ui"])
|
||||
@click.argument("package", required=False)
|
||||
@click.option(
|
||||
"--path",
|
||||
"project_path",
|
||||
type=click.Path(file_okay=False, exists=False),
|
||||
default=".",
|
||||
help="Path to project directory",
|
||||
)
|
||||
@click.option("--name", "project_name", help="Project name (lookup via workspace)")
|
||||
@click.option("--dev", is_flag=True, help="Include dev dependencies")
|
||||
@click.option("--verbose", is_flag=True, help="Enable verbose output")
|
||||
def uninstall(package: str, project_path: str, project_name: str, dev: bool, verbose: bool):
|
||||
if package is None:
|
||||
package = Console.read("Package name to uninstall: ").strip()
|
||||
|
||||
project = resolve_project(Path(project_path), project_name)
|
||||
deps = project.dependencies if not dev else project.dev_dependencies
|
||||
|
||||
try:
|
||||
Pip.command(project_path, "install", package, verbose=verbose)
|
||||
except subprocess.CalledProcessError as e:
|
||||
Console.error(f"Failed to uninstall {package}: exit code {e.returncode}")
|
||||
|
||||
if package in deps:
|
||||
del deps[package]
|
||||
project.save()
|
||||
Console.write_line(f"Removed {package} from project dependencies.")
|
||||
2
src/cpl-cli/cpl/cli/const.py
Normal file
2
src/cpl-cli/cpl/cli/const.py
Normal file
@@ -0,0 +1,2 @@
|
||||
PROJECT_TYPES = ["console", "web", "library", "service"]
|
||||
PROJECT_TYPES_SHORT = [x[0] for x in PROJECT_TYPES]
|
||||
66
src/cpl-cli/cpl/cli/main.py
Normal file
66
src/cpl-cli/cpl/cli/main.py
Normal file
@@ -0,0 +1,66 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from cpl.cli.cli import cli
|
||||
from cpl.cli.command.init import init
|
||||
from cpl.cli.command.install import install
|
||||
from cpl.cli.command.uninstall import uninstall
|
||||
from cpl.cli.model.workspace import Workspace
|
||||
from cpl.cli.utils.custom_command import script_command
|
||||
from cpl.core.configuration import Configuration
|
||||
|
||||
|
||||
def _load_workspace(path: str) -> Workspace | None:
|
||||
path = Path(path)
|
||||
if not path.exists() or path.is_dir():
|
||||
return None
|
||||
|
||||
return Workspace.from_file(path)
|
||||
|
||||
|
||||
def _load_scripts():
|
||||
for p in [
|
||||
"./cpl.workspace.json",
|
||||
"../cpl.workspace.json",
|
||||
"../../cpl.workspace.json",
|
||||
]:
|
||||
ws = _load_workspace(p)
|
||||
if ws is None:
|
||||
continue
|
||||
|
||||
Configuration.set("workspace_path", os.path.abspath(p))
|
||||
return ws.scripts
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
def prepare():
|
||||
scripts = _load_scripts()
|
||||
for name, command in scripts.items():
|
||||
script_command(cli, name, command)
|
||||
|
||||
|
||||
def configure():
|
||||
cli.add_command(init)
|
||||
cli.add_command(install)
|
||||
cli.add_command(uninstall)
|
||||
|
||||
|
||||
def main():
|
||||
prepare()
|
||||
configure()
|
||||
cli()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
# ((
|
||||
# ( `)
|
||||
# ; / ,
|
||||
# / \/
|
||||
# / |
|
||||
# / ~/
|
||||
# / ) ) ~ edraft
|
||||
# ___// | /
|
||||
# `--' \_~-,
|
||||
0
src/cpl-cli/cpl/cli/model/__init__.py
Normal file
0
src/cpl-cli/cpl/cli/model/__init__.py
Normal file
131
src/cpl-cli/cpl/cli/model/cpl_structure_model.py
Normal file
131
src/cpl-cli/cpl/cli/model/cpl_structure_model.py
Normal file
@@ -0,0 +1,131 @@
|
||||
import inspect
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
|
||||
T = TypeVar("T", bound="CPLStructureModel")
|
||||
|
||||
|
||||
class CPLStructureModel:
|
||||
def __init__(self, path: Optional[str] = None):
|
||||
self._path = path
|
||||
|
||||
@property
|
||||
def path(self) -> Optional[str]:
|
||||
return self._path
|
||||
|
||||
@classmethod
|
||||
def from_file(cls: Type[T], path: Path | str) -> T:
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return cls.from_json(data, path=path)
|
||||
|
||||
@classmethod
|
||||
def from_json(cls: Type[T], data: Dict[str, Any], path: Optional[Path | str] = None) -> T:
|
||||
if isinstance(path, str):
|
||||
path = Path(path)
|
||||
|
||||
sig = inspect.signature(cls.__init__)
|
||||
kwargs: Dict[str, Any] = {}
|
||||
for name, param in list(sig.parameters.items())[1:]:
|
||||
if name == "path":
|
||||
kwargs[name] = path
|
||||
continue
|
||||
|
||||
if name in data:
|
||||
kwargs[name] = data[name]
|
||||
continue
|
||||
|
||||
priv = "_" + name
|
||||
if priv in data:
|
||||
kwargs[name] = data[priv]
|
||||
continue
|
||||
|
||||
camel = _self_or_cls_snake_to_camel(name)
|
||||
if camel in data:
|
||||
kwargs[name] = data[camel]
|
||||
continue
|
||||
|
||||
if param.default is not inspect._empty:
|
||||
kwargs[name] = param.default
|
||||
continue
|
||||
|
||||
raise KeyError(f"Missing required field '{name}' for {cls.__name__}.")
|
||||
|
||||
return cls(**kwargs)
|
||||
|
||||
def to_json(self) -> Dict[str, Any]:
|
||||
result: Dict[str, Any] = {}
|
||||
for key, value in self.__dict__.items():
|
||||
if not key.startswith("_") or key == "_path":
|
||||
continue
|
||||
out_key = _self_or_cls_snake_to_camel(key[1:])
|
||||
result[out_key] = value
|
||||
return result
|
||||
|
||||
def save(self):
|
||||
if not self._path:
|
||||
raise ValueError("Cannot save model without a path.")
|
||||
|
||||
with open(self._path, "w", encoding="utf-8") as f:
|
||||
json.dump(self.to_json(), f, indent=2)
|
||||
|
||||
@staticmethod
|
||||
def _require_str(value, field: str, allow_empty: bool = True) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{field} must be of type str")
|
||||
if not allow_empty and not value.strip():
|
||||
raise ValueError(f"{field} must not be empty")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _require_optional_non_empty_str(value, field: str) -> Optional[str]:
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"{field} must be str or None")
|
||||
s = value.strip()
|
||||
if not s:
|
||||
raise ValueError(f"{field} must not be empty when set")
|
||||
return s
|
||||
|
||||
@staticmethod
|
||||
def _require_list_of_str(value, field: str) -> List[str]:
|
||||
if not isinstance(value, list):
|
||||
raise TypeError(f"{field} must be a list")
|
||||
out: List[str] = []
|
||||
for i, v in enumerate(value):
|
||||
if not isinstance(v, str):
|
||||
raise TypeError(f"{field}[{i}] must be of type str")
|
||||
s = v.strip()
|
||||
if s:
|
||||
out.append(s)
|
||||
|
||||
seen = set()
|
||||
uniq: List[str] = []
|
||||
for s in out:
|
||||
if s not in seen:
|
||||
seen.add(s)
|
||||
uniq.append(s)
|
||||
return uniq
|
||||
|
||||
@staticmethod
|
||||
def _require_dict_str_str(value, field: str) -> Dict[str, str]:
|
||||
if not isinstance(value, dict):
|
||||
raise TypeError(f"{field} must be a dict")
|
||||
out: Dict[str, str] = {}
|
||||
for k, v in value.items():
|
||||
if not isinstance(k, str) or not k.strip():
|
||||
raise TypeError(f"Keys in {field} must be non-empty strings")
|
||||
if not isinstance(v, str) or not v.strip():
|
||||
raise TypeError(f"Values in {field} must be non-empty strings")
|
||||
out[k.strip()] = v.strip()
|
||||
return out
|
||||
|
||||
|
||||
def _self_or_cls_snake_to_camel(s: str) -> str:
|
||||
parts = s.split("_")
|
||||
return parts[0] + "".join(p[:1].upper() + p[1:] for p in parts[1:])
|
||||
178
src/cpl-cli/cpl/cli/model/project.py
Normal file
178
src/cpl-cli/cpl/cli/model/project.py
Normal file
@@ -0,0 +1,178 @@
|
||||
import re
|
||||
from typing import Optional, List, Dict
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from cpl.cli.model.cpl_structure_model import CPLStructureModel
|
||||
|
||||
|
||||
class Project(CPLStructureModel):
|
||||
_ALLOWED_TYPES = {"application", "library"}
|
||||
_SEMVER_RE = re.compile(r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$")
|
||||
|
||||
@staticmethod
|
||||
def new(path: str, name: str, project_type: str) -> "Project":
|
||||
return Project(
|
||||
path,
|
||||
name,
|
||||
"0.1.0",
|
||||
project_type,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
[],
|
||||
{},
|
||||
{},
|
||||
[],
|
||||
None,
|
||||
"src",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
name: str,
|
||||
version: str,
|
||||
type: str,
|
||||
license: str,
|
||||
author: str,
|
||||
description: str,
|
||||
homepage: str,
|
||||
keywords: List[str],
|
||||
dependencies: Dict[str, str],
|
||||
dev_dependencies: Dict[str, str],
|
||||
references: List[str],
|
||||
main: Optional[str],
|
||||
directory: str,
|
||||
):
|
||||
CPLStructureModel.__init__(self, path)
|
||||
|
||||
self._name = name
|
||||
self._version = version
|
||||
self._type = type
|
||||
self._license = license
|
||||
self._author = author
|
||||
self._description = description
|
||||
self._homepage = homepage
|
||||
self._keywords = keywords
|
||||
self._dependencies = dependencies
|
||||
self._dev_dependencies = dev_dependencies
|
||||
self._references = references
|
||||
self._main = main
|
||||
self._directory = directory
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = self._require_str(value, "name", allow_empty=False).strip()
|
||||
|
||||
@property
|
||||
def version(self) -> str:
|
||||
return self._version
|
||||
|
||||
@version.setter
|
||||
def version(self, value: str):
|
||||
value = self._require_str(value, "version", allow_empty=False).strip()
|
||||
if not self._SEMVER_RE.match(value):
|
||||
raise ValueError("version must follow SemVer X.Y.Z (optionally with -/+)")
|
||||
self._version = value
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return self._type
|
||||
|
||||
@type.setter
|
||||
def type(self, value: str):
|
||||
value = self._require_str(value, "type", allow_empty=False).strip()
|
||||
if value not in self._ALLOWED_TYPES:
|
||||
allowed = ", ".join(sorted(self._ALLOWED_TYPES))
|
||||
raise ValueError(f"type must be one of: {allowed}")
|
||||
self._type = value
|
||||
|
||||
@property
|
||||
def license(self) -> str:
|
||||
return self._license
|
||||
|
||||
@license.setter
|
||||
def license(self, value: str):
|
||||
self._license = self._require_str(value, "license", allow_empty=True).strip()
|
||||
|
||||
@property
|
||||
def author(self) -> str:
|
||||
return self._author
|
||||
|
||||
@author.setter
|
||||
def author(self, value: str):
|
||||
self._author = self._require_str(value, "author", allow_empty=True).strip()
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._description
|
||||
|
||||
@description.setter
|
||||
def description(self, value: str):
|
||||
self._description = self._require_str(value, "description", allow_empty=True).strip()
|
||||
|
||||
@property
|
||||
def homepage(self) -> str:
|
||||
return self._homepage
|
||||
|
||||
@homepage.setter
|
||||
def homepage(self, value: str):
|
||||
value = self._require_str(value, "homepage", allow_empty=True).strip()
|
||||
if value:
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||||
raise ValueError("homepage must be a valid http/https URL")
|
||||
self._homepage = value
|
||||
|
||||
@property
|
||||
def keywords(self) -> List[str]:
|
||||
return self._keywords
|
||||
|
||||
@keywords.setter
|
||||
def keywords(self, value: List[str]):
|
||||
self._keywords = self._require_list_of_str(value, "keywords")
|
||||
|
||||
@property
|
||||
def dependencies(self) -> Dict[str, str]:
|
||||
return self._dependencies
|
||||
|
||||
@dependencies.setter
|
||||
def dependencies(self, value: Dict[str, str]):
|
||||
self._dependencies = self._require_dict_str_str(value, "dependencies")
|
||||
|
||||
@property
|
||||
def dev_dependencies(self) -> Dict[str, str]:
|
||||
return self._dev_dependencies
|
||||
|
||||
@dev_dependencies.setter
|
||||
def dev_dependencies(self, value: Dict[str, str]):
|
||||
self._dev_dependencies = self._require_dict_str_str(value, "devDependencies")
|
||||
|
||||
@property
|
||||
def references(self) -> List[str]:
|
||||
return self._references
|
||||
|
||||
@references.setter
|
||||
def references(self, value: List[str]):
|
||||
self._references = self._require_list_of_str(value, "references")
|
||||
|
||||
@property
|
||||
def main(self) -> Optional[str]:
|
||||
return self._main
|
||||
|
||||
@main.setter
|
||||
def main(self, value: Optional[str]):
|
||||
self._main = self._require_optional_non_empty_str(value, "main")
|
||||
|
||||
@property
|
||||
def directory(self) -> str:
|
||||
return self._directory
|
||||
|
||||
@directory.setter
|
||||
def directory(self, value: str):
|
||||
self._directory = self._require_str(value, "directory", allow_empty=False).strip()
|
||||
62
src/cpl-cli/cpl/cli/model/workspace.py
Normal file
62
src/cpl-cli/cpl/cli/model/workspace.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from typing import Optional, List, Dict
|
||||
|
||||
from cpl.cli.model.cpl_structure_model import CPLStructureModel
|
||||
|
||||
|
||||
class Workspace(CPLStructureModel):
|
||||
@staticmethod
|
||||
def new(path: str, name: str) -> "Workspace":
|
||||
return Workspace(
|
||||
path=path,
|
||||
name=name,
|
||||
projects=[],
|
||||
default_project=None,
|
||||
scripts={},
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str,
|
||||
name: str,
|
||||
projects: List[str],
|
||||
default_project: Optional[str],
|
||||
scripts: Dict[str, str],
|
||||
):
|
||||
CPLStructureModel.__init__(self, path)
|
||||
|
||||
self._name = name
|
||||
self._projects = projects
|
||||
self._default_project = default_project
|
||||
self._scripts = scripts
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@name.setter
|
||||
def name(self, value: str):
|
||||
self._name = self._require_str(value, "name", allow_empty=False).strip()
|
||||
|
||||
@property
|
||||
def projects(self) -> List[str]:
|
||||
return self._projects
|
||||
|
||||
@projects.setter
|
||||
def projects(self, value: List[str]):
|
||||
self._projects = self._require_list_of_str(value, "projects")
|
||||
|
||||
@property
|
||||
def default_project(self) -> Optional[str]:
|
||||
return self._default_project
|
||||
|
||||
@default_project.setter
|
||||
def default_project(self, value: Optional[str]):
|
||||
self._default_project = self._require_optional_non_empty_str(value, "defaultProject")
|
||||
|
||||
@property
|
||||
def scripts(self) -> Dict[str, str]:
|
||||
return self._scripts
|
||||
|
||||
@scripts.setter
|
||||
def scripts(self, value: Dict[str, str]):
|
||||
self._scripts = self._require_dict_str_str(value, "scripts")
|
||||
0
src/cpl-cli/cpl/cli/utils/__init__.py
Normal file
0
src/cpl-cli/cpl/cli/utils/__init__.py
Normal file
18
src/cpl-cli/cpl/cli/utils/custom_command.py
Normal file
18
src/cpl-cli/cpl/cli/utils/custom_command.py
Normal file
@@ -0,0 +1,18 @@
|
||||
import subprocess
|
||||
|
||||
import click
|
||||
|
||||
|
||||
def script_command(cli_group, name, command):
|
||||
|
||||
@cli_group.command(name)
|
||||
@click.argument("args", nargs=-1)
|
||||
def _run_script(args):
|
||||
click.echo(f"Running script: {name}")
|
||||
try:
|
||||
cmd = command.split() + list(args)
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
click.echo(f"Script '{name}' failed with exit code {e.returncode}", err=True)
|
||||
except FileNotFoundError:
|
||||
click.echo(f"Command not found: {command.split()[0]}", err=True)
|
||||
14
src/cpl-cli/cpl/cli/utils/json.py
Normal file
14
src/cpl-cli/cpl/cli/utils/json.py
Normal file
@@ -0,0 +1,14 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def load_project_json(path: Path) -> dict:
|
||||
if not path.exists():
|
||||
return {}
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def save_project_json(path: Path, data: dict):
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||
50
src/cpl-cli/cpl/cli/utils/pip.py
Normal file
50
src/cpl-cli/cpl/cli/utils/pip.py
Normal file
@@ -0,0 +1,50 @@
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from cpl.cli.utils.venv import ensure_venv, get_venv_pip
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
class Pip:
|
||||
|
||||
@staticmethod
|
||||
def normalize_dep(name: str, raw: str) -> str:
|
||||
raw = raw.strip()
|
||||
if not raw:
|
||||
return name
|
||||
|
||||
table = {
|
||||
"!": "!=",
|
||||
">": ">=",
|
||||
">>": ">",
|
||||
"<": "<=",
|
||||
"<<": "<",
|
||||
"~": "~=",
|
||||
"=": "===",
|
||||
}
|
||||
|
||||
op = "=="
|
||||
for prefix, pip_op in table.items():
|
||||
if raw.startswith(prefix):
|
||||
op = pip_op
|
||||
raw = raw[len(prefix) :]
|
||||
break
|
||||
|
||||
return f"{name}{op}{raw}"
|
||||
|
||||
@staticmethod
|
||||
def command(project_path: str, command: str, *args,verbose:bool=False):
|
||||
venv_path = ensure_venv(Path(project_path))
|
||||
pip = get_venv_pip(venv_path)
|
||||
|
||||
if verbose:
|
||||
Console.write_line()
|
||||
|
||||
subprocess.run(
|
||||
f"{pip} {command} {''.join(args)}",
|
||||
shell=True,
|
||||
check=True,
|
||||
stdin=subprocess.DEVNULL if not verbose else None,
|
||||
stdout=subprocess.DEVNULL if not verbose else None,
|
||||
stderr=subprocess.STDOUT if not verbose else None,
|
||||
)
|
||||
20
src/cpl-cli/cpl/cli/utils/prompt.py
Normal file
20
src/cpl-cli/cpl/cli/utils/prompt.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import click
|
||||
|
||||
|
||||
class SmartChoice(click.Choice):
|
||||
|
||||
def __init__(self, choices: list, aliases: dict = None):
|
||||
click.Choice.__init__(self, choices, case_sensitive=False)
|
||||
|
||||
self._aliases = {c: c[0].lower() for c in choices if len(c) > 0}
|
||||
if aliases:
|
||||
self._aliases.update({k: v.lower() for k, v in aliases.items()})
|
||||
|
||||
if any([x[0].lower in self._aliases for x in choices if len(x) > 1]):
|
||||
raise ValueError("Alias conflict with first letters of choices")
|
||||
|
||||
def convert(self, value, param, ctx):
|
||||
val_lower = value.lower()
|
||||
if val_lower in self._aliases.values():
|
||||
value = [k for k, v in self._aliases.items() if v == val_lower][0]
|
||||
return super().convert(value, param, ctx)
|
||||
30
src/cpl-cli/cpl/cli/utils/structure.py
Normal file
30
src/cpl-cli/cpl/cli/utils/structure.py
Normal file
@@ -0,0 +1,30 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cpl.cli.model.project import Project
|
||||
from cpl.cli.model.workspace import Workspace
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
def resolve_project(path: Path, name: str | None) -> Project:
|
||||
project_file = path / "cpl.project.json"
|
||||
if project_file.exists():
|
||||
return Project.from_file(project_file)
|
||||
|
||||
workspace_file = path / "cpl.workspace.json"
|
||||
if workspace_file.exists():
|
||||
workspace = Workspace.from_file(workspace_file)
|
||||
if name:
|
||||
for p in workspace.projects:
|
||||
project = Project.from_file(p)
|
||||
if project.name == name:
|
||||
return project
|
||||
|
||||
elif workspace.default_project:
|
||||
for p in workspace.projects:
|
||||
project = Project.from_file(p)
|
||||
if project.name == workspace.default_project:
|
||||
return project
|
||||
|
||||
Console.error(f"Could not find project file '{path}'")
|
||||
exit(1)
|
||||
44
src/cpl-cli/cpl/cli/utils/venv.py
Normal file
44
src/cpl-cli/cpl/cli/utils/venv.py
Normal file
@@ -0,0 +1,44 @@
|
||||
import os
|
||||
import sys
|
||||
import venv
|
||||
from pathlib import Path
|
||||
|
||||
from cpl.core.configuration import Configuration
|
||||
from cpl.core.console import Console
|
||||
|
||||
|
||||
def ensure_venv(start_path: Path | None = None) -> Path:
|
||||
start_path = start_path or Path.cwd()
|
||||
workspace_path = Configuration.get("workspace_path")
|
||||
|
||||
if workspace_path is not None:
|
||||
workspace_path = Path(os.path.dirname(workspace_path))
|
||||
|
||||
ws_venv = workspace_path / ".venv"
|
||||
if ws_venv.exists():
|
||||
return ws_venv
|
||||
|
||||
for parent in [start_path, *start_path.parents]:
|
||||
venv_path = parent / ".venv"
|
||||
if venv_path.exists():
|
||||
return venv_path
|
||||
|
||||
if workspace_path is not None:
|
||||
venv_path = workspace_path / ".venv"
|
||||
else:
|
||||
venv_path = start_path / ".venv"
|
||||
|
||||
Console.write_line(f"Creating virtual environment at {venv_path}...")
|
||||
venv.EnvBuilder(with_pip=True).create(venv_path)
|
||||
return venv_path
|
||||
|
||||
|
||||
def get_venv_python(venv_path: Path) -> Path:
|
||||
if sys.platform == "win32":
|
||||
return venv_path / "Scripts" / "python.exe"
|
||||
return venv_path / "bin" / "python"
|
||||
|
||||
|
||||
def get_venv_pip(venv_path: Path) -> str:
|
||||
python_exe = get_venv_python(venv_path)
|
||||
return f"{python_exe} -m pip"
|
||||
29
src/cpl-cli/pyproject.toml
Normal file
29
src/cpl-cli/pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=70.1.0", "wheel>=0.43.0"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "cpl-cli"
|
||||
version = "2024.7.0"
|
||||
description = "CPL cli"
|
||||
readme = "CPL cli package"
|
||||
requires-python = ">=3.12"
|
||||
license = "MIT"
|
||||
authors = [
|
||||
{ name = "Sven Heidemann", email = "sven.heidemann@sh-edraft.de" }
|
||||
]
|
||||
keywords = ["cpl", "cli", "backend", "shared", "library"]
|
||||
|
||||
dynamic = ["dependencies", "optional-dependencies"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://www.sh-edraft.de"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["cpl*"]
|
||||
|
||||
[tool.setuptools.dynamic]
|
||||
dependencies = { file = ["requirements.txt"] }
|
||||
optional-dependencies.dev = { file = ["requirements.dev.txt"] }
|
||||
|
||||
1
src/cpl-cli/requirements.dev.txt
Normal file
1
src/cpl-cli/requirements.dev.txt
Normal file
@@ -0,0 +1 @@
|
||||
black==25.1.0
|
||||
1
src/cpl-cli/requirements.txt
Normal file
1
src/cpl-cli/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
cpl-core
|
||||
24
src/cpl-core/cpl.project.json
Normal file
24
src/cpl-core/cpl.project.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "cpl-core",
|
||||
"version": "0.1.0",
|
||||
"type": "library",
|
||||
"license": "MIT",
|
||||
"author": "Sven Heidemann",
|
||||
"description": "CLI for the CPL library",
|
||||
"homepage": "",
|
||||
"keywords": [],
|
||||
"dependencies": {
|
||||
"art": "~6.5",
|
||||
"colorama": "~0.4.6",
|
||||
"tabulate": "~0.9.0",
|
||||
"termcolor": "~3.1.0",
|
||||
"pynput": "~1.8.1",
|
||||
"croniter": "~6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"black": "25.1.0"
|
||||
},
|
||||
"references": [],
|
||||
"main": null,
|
||||
"directory": "cpl/core"
|
||||
}
|
||||
Reference in New Issue
Block a user