WIP: dev into master #184

Draft
edraft wants to merge 121 commits from dev into master
25 changed files with 901 additions and 0 deletions
Showing only changes of commit 0a6a17acf6 - Show all commits

11
cpl.workspace.json Normal file
View 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"
}
}

View 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"
}

View File

View 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(): ...

View File

View 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}'")

View 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}")

View 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.")

View File

@@ -0,0 +1,2 @@
PROJECT_TYPES = ["console", "web", "library", "service"]
PROJECT_TYPES_SHORT = [x[0] for x in PROJECT_TYPES]

View 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
# ___// | /
# `--' \_~-,

View File

View 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:])

View 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()

View 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")

View File

View 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)

View 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)

View 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,
)

View 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)

View 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)

View 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"

View 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"] }

View File

@@ -0,0 +1 @@
black==25.1.0

View File

@@ -0,0 +1 @@
cpl-core

View 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"
}