WIP: dev into master #184
11
cpl.workspace.json
Normal file
11
cpl.workspace.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"name": "cpl",
|
||||||
|
"projects": [
|
||||||
|
"src/cpl-core/cpl.project.json",
|
||||||
|
"src/cpl-cli/cpl.project.json"
|
||||||
|
],
|
||||||
|
"defaultProject": "cpl-cli",
|
||||||
|
"scripts": {
|
||||||
|
"format": "black src"
|
||||||
|
}
|
||||||
|
}
|
||||||
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