"""Project Module."""
from __future__ import annotations
__all__ = ("Project",)
import copy
import dataclasses
import datetime
import importlib.metadata
import importlib.util
import pathlib
import shutil
import subprocess
import sys
import sysconfig
import types
from contextvars import ContextVar
from typing import ClassVar
from .classes import ColorLogger, ConfigParser
from .constants import (
AUTHOR,
CI,
DOCKER,
DOCKER_COMMAND,
EMAIL,
GIT,
NODEPS_PIP_POST_INSTALL_FILENAME,
NODEPS_PROJECT_NAME,
NODEPS_TOP,
PYTHON_DEFAULT_VERSION,
PYTHON_VERSIONS,
)
from .enums import Bump, ProjectRepos
from .errors import CalledProcessError, InvalidArgumentError
from .functions import completions, dict_sort, exec_module_from_file, findfile, findup, in_tox, suppress, urljson, which
from .gh import Gh
from .metapath import pipmetapathfinder
from .path import FileConfig, Path, toiter
NODEPS_QUIET: ContextVar[bool] = ContextVar("NODEPS_QUIET", default=True)
"""Global variable to supress warn in setuptools"""
[docs]
@dataclasses.dataclass
class Project:
"""Project Class."""
data: Path | str | types.ModuleType = None
"""File, directory or name (str or path with one word) of project (default: current working directory)"""
brewfile: Path | None = dataclasses.field(default=None, init=False)
"""Data directory Brewfile"""
ci: bool = dataclasses.field(default=False, init=False)
"""running in CI or tox"""
data_dir: Path | None = dataclasses.field(default=None, init=False)
"""Data directory"""
directory: Path | None = dataclasses.field(default=None, init=False)
"""Parent of data if data is a file or None if it is a name (one word)"""
docsdir: Path | None = dataclasses.field(default=None, init=False)
"""Docs directory"""
gh: Gh = dataclasses.field(default=None, init=False)
git: str = dataclasses.field(default="git", init=False)
"""git -C directory if self.directory is not None"""
installed: bool = dataclasses.field(default=False, init=False)
name: str = dataclasses.field(default=None, init=False)
"""Pypi project name from setup.cfg, pyproject.toml or top name or self.data when is one word"""
profile: Path | None = dataclasses.field(default=None, init=False)
"""Data directory profile.d"""
pyproject_toml: FileConfig = dataclasses.field(default_factory=FileConfig, init=False)
repo: Path = dataclasses.field(default=None, init=False)
"""top or superproject"""
root: Path = dataclasses.field(default=None, init=False)
"""pyproject.toml or setup.cfg parent or superproject or top directory"""
source: Path | None = dataclasses.field(default=None, init=False)
"""sources directory, parent of __init__.py or module path"""
clean_match: ClassVar[list[str]] = ["*.egg-info", "build", "dist"]
rm: dataclasses.InitVar[bool] = False
"""remove cache"""
def __post_init__(self, rm: bool = False): # noqa: PLR0912, PLR0915
"""Post init."""
self.ci = any([in_tox(), CI, DOCKER])
self.data = self.data if self.data else Path.cwd()
data = Path(self.data.__file__ if isinstance(self.data, types.ModuleType) else self.data)
if (
(isinstance(self.data, str) and len(toiter(self.data, split="/")) == 1)
or (isinstance(self.data, pathlib.PosixPath) and len(self.data.parts) == 1)
) and (str(self.data) != "/"):
if r := self.repos(ret=ProjectRepos.DICT, rm=rm).get(
self.data if isinstance(self.data, str) else self.data.name
):
self.directory = r
elif data.is_dir():
self.directory = data.absolute()
elif data.is_file():
self.directory = data.parent.absolute()
else:
msg = f"Invalid argument: {self.data=}"
raise InvalidArgumentError(msg)
if self.directory:
self.git = f"git -C '{self.directory}'"
if (path := findup(self.directory, name="pyproject.toml", uppermost=True)) and (
path.parent / ".git"
).exists():
path = path[0] if isinstance(path, list) else path
with pipmetapathfinder():
import tomlkit
with Path.open(path, "rb") as f:
self.pyproject_toml = FileConfig(path, tomlkit.load(f))
self.name = self.pyproject_toml.config.get("project", {}).get("name")
self.root = path.parent
elif (path := findup(self.directory, name=".git", kind="exists", uppermost=True)) and (
path.parent / ".git"
).exists():
self.root = path.parent
self.name = self.root.name
if self.root:
self.gh = Gh(self.root)
self.repo = self.gh.top() or self.gh.superproject()
purelib = sysconfig.get_paths()["purelib"]
if root := self.root or self.repo:
self.root = root.absolute()
if (src := (root / "src")) and (str(src) not in sys.path):
sys.path.insert(0, str(src))
elif self.directory.is_relative_to(purelib):
self.name = Path(self.directory).relative_to(purelib).parts[0]
self.name = self.name if self.name else self.root.name if self.root else None
else:
self.name = str(self.data)
try:
if self.name and ((spec := importlib.util.find_spec(self.name)) and spec.origin):
self.source = Path(spec.origin).parent if "__init__.py" in spec.origin else Path(spec.origin)
self.installed = True
self.root = self.root if self.root else self.source.parent
purelib = sysconfig.get_paths()["purelib"]
self.installed = bool(self.source.is_relative_to(purelib) or Path(purelib).name in str(self.source))
except (ModuleNotFoundError, ImportError):
pass
if self.source:
self.data_dir = d if (d := self.source / "data").is_dir() else None
if self.data_dir:
self.brewfile = b if (b := self.data_dir / "Brewfile").is_file() else None
self.profile = pr if (pr := self.data_dir / "profile.d").is_dir() else None
if self.root:
self.docsdir = doc if (doc := self.root / "docs").is_dir() else None
if self.gh is None and (self.root / ".git").exists():
self.gh = Gh(self.root)
self.log = ColorLogger.logger(__name__)
[docs]
def info(self, msg: str):
"""Logger info."""
self.log.info(msg, extra={"extra": self.name})
[docs]
def warning(self, msg: str):
"""Logger warning."""
self.log.warning(msg, extra={"extra": self.name})
[docs]
def bin(self, executable: str | None = None, version: str = PYTHON_DEFAULT_VERSION) -> Path: # noqa: A003
"""Bin directory.
Args;
executable: command to add to path
version: python version
"""
return Path(self.executable(version=version)).parent / executable if executable else ""
[docs]
def brew(self, c: str | None = None) -> int:
"""Runs brew bundle."""
if which("brew") and self.brewfile and (c is None or not which(c)):
rv = subprocess.run(
[
"brew",
"bundle",
"--no-lock",
"--quiet",
f"--file={self.brewfile}",
],
stdout=subprocess.PIPE,
).returncode
self.info(self.brew.__name__)
return rv
return 0
[docs]
def browser(self, version: str = PYTHON_DEFAULT_VERSION, quiet: bool = True) -> int:
"""Build and serve the documentation with live reloading on file changes.
Arguments:
version: python version
quiet: quiet mode (default: True)
"""
ContextVar("NODEPS_QUIET").set(quiet)
if not self.docsdir:
return 0
build_dir = self.docsdir / "_build"
q = "-Q" if quiet else ""
if build_dir.exists():
shutil.rmtree(build_dir)
if (
subprocess.check_call(
f"{self.executable(version=version)} -m sphinx_autobuild {q} {self.docsdir} {build_dir}",
shell=True
)
== 0
):
self.info(self.docs.__name__)
return 0
[docs]
def build(self, version: str = PYTHON_DEFAULT_VERSION, quiet: bool = True, rm: bool = False) -> Path | None:
"""Build a project `venv`, `completions`, `docs` and `clean`.
Arguments:
version: python version (default: PYTHON_DEFAULT_VERSION)
quiet: quiet mode (default: True)
rm: remove cache
"""
# HACER: el pth sale si execute en terminal pero no en run
ContextVar("NODEPS_QUIET").set(quiet)
if not self.pyproject_toml.file:
return None
self.venv(version=version, quiet=quiet, rm=rm)
self.completions()
self.docs(quiet=quiet)
self.clean()
rv = subprocess.run(
f"{self.executable(version=version)} -m build {self.root} --wheel",
stdout=subprocess.PIPE,
shell=True,
)
if rv.returncode != 0:
sys.exit(rv.returncode)
wheel = rv.stdout.splitlines()[-1].decode().split(" ")[2]
if "py3-none-any.whl" not in wheel:
raise CalledProcessError(completed=rv)
self.info(
f"{self.build.__name__}: {wheel}: {version}",
)
return self.root / "dist" / wheel
[docs]
def builds(self, quiet: bool = True, rm: bool = False) -> None:
"""Build a project `venv`, `completions`, `docs` and `clean`.
Arguments:
quiet: quiet mode (default: True)
rm: remove cache
"""
ContextVar("NODEPS_QUIET").set(quiet)
if self.ci:
self.build(quiet=quiet, rm=rm)
else:
for version in PYTHON_VERSIONS:
self.build(version=version, quiet=quiet, rm=rm)
[docs]
def buildrequires(self) -> list[str]:
"""pyproject.toml build-system requires."""
if self.pyproject_toml.file:
return self.pyproject_toml.config.get("build-system", {}).get("requires", [])
return []
[docs]
def clean(self) -> None:
"""Clean project."""
if not in_tox():
for item in self.clean_match:
try:
for file in self.root.rglob(item):
if file.is_dir():
shutil.rmtree(self.root / item, ignore_errors=True)
else:
file.unlink(missing_ok=True)
except FileNotFoundError:
pass
[docs]
def completions(self, uninstall: bool = False):
"""Generate completions to /usr/local/etc/bash_completion.d."""
value = []
if self.pyproject_toml.file:
value = self.pyproject_toml.config.get("project", {}).get("scripts", {}).keys()
elif d := self.distribution():
value = [item.name for item in d.entry_points]
if value:
for item in value:
if file := completions(item, uninstall=uninstall):
self.info(f"{self.completions.__name__}: {item} -> {file}")
[docs]
def coverage(self) -> int:
"""Runs coverage."""
if (
self.pyproject_toml.file
and subprocess.check_call(f"{self.executable()} -m coverage run -m pytest {self.root}",
shell=True) == 0
and subprocess.check_call(f"{self.executable()} -m coverage report "
f"--data-file={self.root}/reports/.coverage", shell=True) == 0
):
self.info(self.coverage.__name__)
return 0
[docs]
def dependencies(self) -> list[str]:
"""Dependencies from pyproject.toml or distribution."""
if self.pyproject_toml.config:
return self.pyproject_toml.config.get("project", {}).get("dependencies", [])
if d := self.distribution():
return [item for item in d.requires if "; extra" not in item]
msg = f"Dependencies not found for {self.name=}"
raise RuntimeWarning(msg)
[docs]
def distribution(self) -> importlib.metadata.Distribution | None:
"""Distribution."""
return suppress(importlib.metadata.Distribution.from_name, self.name)
[docs]
def docker(self, quiet: bool = True) -> int:
"""Docker push.
Arguments:
quiet: quiet mode (default: True)
"""
ContextVar("NODEPS_QUIET").set(quiet)
rc = 0
if not DOCKER and DOCKER_COMMAND and (dockerfile := self.root / "Dockerfile").is_file():
for version in PYTHON_VERSIONS:
tag = f"{GIT}/{self.name}:{version}"
latest = f"-t {GIT}/{self.name}" if version == PYTHON_DEFAULT_VERSION else ""
quiet = "--quiet" if quiet else ""
command = (
f"docker build -f {dockerfile} {quiet} --build-arg='PY_VERSION={version}' "
f"-t {tag} {latest} {self.root}"
)
if rc := subprocess.run(command, stdout=subprocess.PIPE, shell=True).returncode != 0:
return rc
latest = latest.strip("-t ")
for image in [tag, latest]:
if image:
command = f"docker push {quiet} {image}"
if rc := subprocess.run(command, stdout=subprocess.PIPE, shell=True).returncode != 0:
return rc
self.info(f"{self.docker.__name__}: {image}")
return int(rc)
[docs]
def docs(self, version: str = PYTHON_DEFAULT_VERSION, quiet: bool = True) -> int:
"""Build the documentation.
Arguments:
version: python version
quiet: quiet mode (default: True)
"""
ContextVar("NODEPS_QUIET").set(quiet)
if not self.docsdir:
return 0
build_dir = self.docsdir / "_build"
q = "-Q" if quiet else ""
if build_dir.exists():
shutil.rmtree(build_dir)
if (
subprocess.check_call(
f"{self.executable(version=version)} -m sphinx {q} --color {self.docsdir} {build_dir}",
shell=True,
)
== 0
):
self.info(f"{self.docs.__name__}: {version}")
return 0
[docs]
def executable(self, version: str = PYTHON_DEFAULT_VERSION) -> Path:
"""Executable."""
return v / f"bin/python{version}" if (v := self.root / "venv").is_dir() and not self.ci else sys.executable
@staticmethod
def _extras(d):
e = {}
for item in d:
if "; extra" in item:
key = item.split("; extra == ")[1].replace("'", "").replace('"', "").removesuffix(" ")
if key not in e:
e[key] = []
e[key].append(item.split("; extra == ")[0].replace('"', "").removesuffix(" "))
return e
[docs]
@classmethod
def nodeps(cls) -> Project:
"""Project Instance of nodeps."""
return cls(__file__)
[docs]
def post(self, uninstall: bool = False) -> None:
"""Run post install for package: completions, brew and _post_install.py."""
if uninstall:
self.completions(uninstall=True)
return
self.completions()
self.brew()
for file in findfile(NODEPS_PIP_POST_INSTALL_FILENAME, self.root or self.source or self.directory):
if ".tox" not in file:
self.info(f"{self.post.__name__}: {file}")
exec_module_from_file(file)
[docs]
def publish(
self,
part: Bump = Bump.PATCH,
force: bool = False,
ruff: bool = True,
tox: bool = False,
quiet: bool = True,
rm: bool = False,
):
"""Publish runs runs `tests`, `commit`, `tag`, `push`, `twine` and `clean`.
Args:
part: part to increase if force
force: force bump
ruff: run ruff
tox: run tox
quiet: quiet mode (default: True)
rm: remove cache
"""
ContextVar("NODEPS_QUIET").set(quiet)
self.tests(ruff=ruff, tox=tox, quiet=quiet)
self.gh.commit()
if (n := self.gh.next(part=part, force=force)) != (l := self.gh.latest()):
self.gh.tag(n)
self.gh.push()
if rc := self.twine(rm=rm) != 0:
sys.exit(rc)
self.info(f"{self.publish.__name__}: {l} -> {n}")
if rc := self.docker(quiet=quiet) != 0:
sys.exit(rc)
else:
self.warning(f"{self.publish.__name__}: {n} -> nothing to do")
self.clean()
[docs]
def pypi(
self,
rm: bool = False,
) -> dict[str, str | list | dict[str, str | list | dict[str, str | list]]]:
"""Pypi information for a package.
Examples:
>>> from nodeps import Project
>>> from nodeps import NODEPS_PROJECT_NAME
>>>
>>> assert Project(NODEPS_PROJECT_NAME).pypi()["info"]["name"] == NODEPS_PROJECT_NAME
Returns:
dict: pypi information
rm: use pickle cache or remove it.
"""
return urljson(f"https://pypi.org/pypi/{self.name}/json", rm=rm)
[docs]
def pytest(self, version: str = PYTHON_DEFAULT_VERSION) -> int:
"""Runs pytest."""
if self.pyproject_toml.file:
rc = subprocess.run(f"{self.executable(version=version)} -m pytest {self.root}", shell=True).returncode
self.info(f"{self.pytest.__name__}: {version}")
return rc
return 0
[docs]
def pytests(self) -> int:
"""Runs pytest for all versions."""
rc = 0
if self.ci:
rc = self.pytest()
else:
for version in PYTHON_VERSIONS:
rc = self.pytest(version=version)
if rc != 0:
sys.exit(rc)
return rc
[docs]
@classmethod
def repos(
cls,
ret: ProjectRepos = ProjectRepos.NAMES,
sync: bool = False,
archive: bool = False,
rm: bool = False,
) -> list[Path] | list[str] | dict[str, Project | str] | None:
"""Repo paths, names or Project instances under home, Archive or parent of nodeps top.
Examples:
>>> from nodeps import Project
>>> from nodeps import NODEPS_PROJECT_NAME, Path, NODEPS_TOP
>>>
>>> assert NODEPS_PROJECT_NAME in Project.repos()
>>> assert NODEPS_PROJECT_NAME in Project.repos(ProjectRepos.DICT)
>>> assert NODEPS_PROJECT_NAME in Project.repos(ProjectRepos.INSTANCES)
>>> assert NODEPS_PROJECT_NAME in Project.repos(ProjectRepos.PY)
>>>
>>> shrc = Path.home() / "shrc/.git"
>>> if shrc.is_dir():
... assert "shrc" not in Project.repos(ProjectRepos.PY)
... assert "shrc" in Project.repos()
Args:
ret: return names, paths, dict or instances
sync: push or pull all repos
archive: look for repos under ~/Archive
rm: remove cache
"""
if archive:
rm = True
if rm or not (rv := Path.pickle(name=cls.repos)):
dev = home = Path.home()
add = sorted(add.iterdir()) if (add := home / "Archive").is_dir() and archive else []
dev = sorted(dev.iterdir()) if NODEPS_TOP and (dev := NODEPS_TOP.parent) != home else []
rv = {
ProjectRepos.DICT: {},
ProjectRepos.INSTANCES: {},
ProjectRepos.NAMES: [],
ProjectRepos.PATHS: [],
ProjectRepos.PY: {},
}
for path in add + dev + sorted(home.iterdir()):
if path.is_dir() and (path / ".git").exists() and Gh(path).admin(rm=rm):
instance = cls(path)
name = path.name
rv[ProjectRepos.DICT] |= {name: path}
rv[ProjectRepos.INSTANCES] |= {name: instance}
rv[ProjectRepos.NAMES].append(name)
rv[ProjectRepos.PATHS].append(path)
if instance.pyproject_toml.file:
rv[ProjectRepos.PY] |= {name: instance}
if not archive:
Path.pickle(name=cls.repos, data=rv, rm=rm)
if not rv:
rv: dict[ProjectRepos, dict[str, Project] | list[str | Path]] = Path.pickle(name=cls.repos)
if sync:
for item in rv[ProjectRepos.INSTANCES].values():
msg = f"{cls.repos.__name__}: sync -> {item.name}"
item.log.info(msg)
item.gh.sync()
return None
return rv[ret]
[docs]
def requirement(
self,
version: str = PYTHON_DEFAULT_VERSION,
install: bool = False,
upgrade: bool = False,
quiet: bool = True,
rm: bool = False,
) -> list[str] | int:
"""Dependencies and optional dependencies from pyproject.toml or distribution."""
ContextVar("NODEPS_QUIET").set(quiet)
req = sorted({*self.dependencies() + self.extras(as_list=True, rm=rm)})
req = [item for item in req if not item.startswith(f"{self.name}[")]
if (install or upgrade) and req:
upgrade = ["--upgrade"] if upgrade else []
quiet = "-q" if quiet else ""
rv = subprocess.check_call([self.executable(version), "-m", "pip", "install", quiet, *upgrade, *req])
self.info(f"{self.requirements.__name__}: {version}")
return rv
return req
[docs]
def requirements(
self,
upgrade: bool = False,
quiet: bool = True,
rm: bool = False,
) -> None:
"""Install dependencies and optional dependencies from pyproject.toml or distribution for python versions."""
ContextVar("NODEPS_QUIET").set(quiet)
if self.ci:
self.requirement(install=True, upgrade=upgrade, quiet=quiet, rm=rm)
else:
for version in PYTHON_VERSIONS:
self.requirement(version=version, install=True, upgrade=upgrade, quiet=quiet, rm=rm)
[docs]
def ruff(self, version: str = PYTHON_DEFAULT_VERSION) -> int:
"""Runs ruff."""
if self.pyproject_toml.file:
rv = subprocess.run(f"{self.executable(version=version)} -m ruff check {self.root}", shell=True).returncode
self.info(f"{self.ruff.__name__}: {version}")
return rv
return 0
# HACER: delete all tags and pypi versions
[docs]
def test(
self, version: str = PYTHON_DEFAULT_VERSION, ruff: bool = True, tox: bool = False, quiet: bool = True
) -> int:
"""Test project, runs `build`, `ruff`, `pytest` and `tox`.
Arguments:
version: python version
ruff: run ruff (default: True)
tox: run tox (default: True)
quiet: quiet mode (default: True)
"""
ContextVar("NODEPS_QUIET").set(quiet)
self.build(version=version, quiet=quiet)
if ruff and (rc := self.ruff(version=version) != 0):
sys.exit(rc)
if rc := self.pytest(version=version) != 0:
sys.exit(rc)
if tox and (rc := self.tox() != 0):
sys.exit(rc)
return rc
[docs]
def tests(self, ruff: bool = True, tox: bool = False, quiet: bool = True) -> int:
"""Test project, runs `build`, `ruff`, `pytest` and `tox` for all versions.
Arguments:
ruff: runs ruff
tox: runs tox
quiet: quiet mode (default: True)
"""
ContextVar("NODEPS_QUIET").set(quiet)
rc = 0
if self.ci:
rc = self.test(ruff=ruff, tox=tox, quiet=quiet)
else:
for version in PYTHON_VERSIONS:
rc = self.test(version=version, ruff=ruff, tox=tox, quiet=quiet)
if rc != 0:
sys.exit(rc)
return rc
[docs]
def tox(self) -> int:
"""Runs tox."""
if self.pyproject_toml.file and not self.ci:
rv = subprocess.run(f"{self.executable()} -m tox --root {self.root}", shell=True).returncode
self.info(self.tox.__name__)
return rv
return 0
[docs]
def twine(
self,
part: Bump = Bump.PATCH,
force: bool = False,
rm: bool = False,
) -> int:
"""Twine.
Args:
part: part to increase if force
force: force bump
rm: remove cache
"""
pypi = d.version if (d := self.distribution()) else None
if (
self.pyproject_toml.file
and (pypi != self.gh.next(part=part, force=force))
and "Private :: Do Not Upload" not in self.pyproject_toml.config.get("project", {}).get("classifiers",
[])
):
c = f"{self.executable()} -m twine upload -u __token__ {self.build(rm=rm).parent}/*"
rc = subprocess.run(c, shell=True).returncode
if rc != 0:
return rc
return 0
[docs]
def venv(
self,
version: str = PYTHON_DEFAULT_VERSION,
clear: bool = False,
upgrade: bool = False,
quiet: bool = True,
rm: bool = False,
) -> None:
"""Creates venv, runs: `write` and `requirements`.
Args:
version: python version
clear: remove venv
upgrade: upgrade packages
quiet: quiet
rm: remove cache
"""
ContextVar("NODEPS_QUIET").set(quiet)
version = "" if self.ci else version
if not self.pyproject_toml.file:
return
if not self.root:
msg = f"Undefined: {self.root=} for {self.name=} {self.directory=}"
raise RuntimeError(msg)
self.write(rm=rm)
if not self.ci:
v = self.root / "venv"
python = f"python{version}"
clear = "--clean" if clear else ""
subprocess.check_call(f"{python} -m venv {v} --prompt '.' {clear} --upgrade-deps --upgrade", shell=True)
self.info(f"{self.venv.__name__}: {version}")
self.requirement(version=version, install=True, upgrade=upgrade, quiet=quiet, rm=rm)
[docs]
def venvs(
self,
upgrade: bool = False,
quiet: bool = True,
rm: bool = False,
):
"""Installs venv for all python versions in :data:`PYTHON_VERSIONS`."""
ContextVar("NODEPS_QUIET").set(quiet)
if self.ci:
self.venv(upgrade=upgrade, quiet=quiet, rm=rm)
else:
for version in PYTHON_VERSIONS:
self.venv(version=version, upgrade=upgrade, quiet=quiet, rm=rm)
[docs]
def version(self, rm: bool = True) -> str:
"""Version from pyproject.toml, tag, distribution or pypi.
Args:
rm: remove cache
"""
if v := self.pyproject_toml.config.get("project", {}).get("version"):
return v
if self.gh.top() and (v := self.gh.latest()):
return v
if d := self.distribution():
return d.version
if version_pypi := self.version_pypi(rm=rm):
return version_pypi
msg = f"Version not found for {self.name=} {self.directory=}"
raise RuntimeWarning(msg)
[docs]
def version_pypi(self, rm: bool = True) -> str | None:
"""Pypi version.
Args:
rm: remove cache
"""
if pypi := self.pypi(rm=rm):
return pypi["info"]["version"]
return None
[docs]
def write(self, rm: bool = False): # noqa: PLR0912, PLR0915
"""Updates setup.cfg (cmdclass, scripts), pyproject.toml and docs conf.py.
[options.data_files]
bin =
bin/*
;/etc/gitconfig =
; .gitconfig
;/etc/profile.d =
; lib/*
;etc/gh =
; gh/*
Args:
rm: remove cache
"""
setup_cfg = self.root / "setup.cfg"
if self.root and self.pyproject_toml.file and not setup_cfg.is_file():
setup_cfg.touch()
if self.root and setup_cfg.is_file():
config = ConfigParser()
config.read(setup_cfg)
changed = False
if (bindir := self.root / "bin").is_dir():
scripts = config.getlist()
new_scripts = [str(item.relative(self.root)) for item in sorted(bindir.iterdir()) if
not item.name.startswith(".")]
if new_scripts != scripts:
changed = True
config.setlist(value=new_scripts)
cmdclass = config.getlist(option="cmdclass")
new_cmdclass = [
f"build_py = {NODEPS_PROJECT_NAME}.BuildPy",
f"develop = {NODEPS_PROJECT_NAME}.Develop",
f"easy_install = {NODEPS_PROJECT_NAME}.EasyInstall",
f"install_lib = {NODEPS_PROJECT_NAME}.InstallLib",
]
if new_cmdclass != cmdclass:
changed = True
config.setlist(option="cmdclass", value=new_cmdclass)
options = [
("options.package_data", self.name, "*.pth"),
("options.package_data", f"{self.name}.data", "*"),
("options", "package_dir", "= src"),
]
for item in options:
section_name = item[0]
option = item[1]
option_value_item = item[2]
if not config.has_section(section_name):
changed = True
config.add_section(section_name)
if config.has_option(section_name, option):
option_value = config.getlist(section_name, option)
if option_value_item not in option_value:
option_value.insert(0, option_value_item)
config.setlist(section_name, option, option_value)
changed = True
else:
config.setlist(section_name, option, [option_value_item])
changed = True
options = [
("global", "quiet", "1"),
("global", "verbose", "0"),
("global", "show_warnings", "false"),
("egg_info", "egg_base", "/tmp"), # noqa: S108
("options", "include_package_data", "True"),
("options", "packages", "find:"),
("options.packages.find", "where", "src"),
]
for item in options:
section_name = item[0]
option = item[1]
option_value_item = item[2]
if not config.has_section(section_name):
changed = True
config.add_section(section_name)
if not config.has_option(section_name, option):
config.set(section_name, option, option_value_item)
changed = True
if changed is True:
with setup_cfg.open(mode="w", encoding="utf-8") as cfgfile:
config.write(cfgfile)
self.info(f"{self.write.__name__}: {setup_cfg}")
if self.pyproject_toml.file:
original_project = copy.deepcopy(self.pyproject_toml.config.get("project", {}))
github = self.gh.github(rm=rm)
project = {
"name": github["name"],
"authors": [
{"name": AUTHOR, "email": EMAIL},
],
"description": github.get("description", ""),
"urls": {"Homepage": github["html_url"], "Documentation": f"https://{self.name}.readthedocs.io"},
"dynamic": ["version"],
"license": {"text": "MIT"},
"readme": "README.md",
"requires-python": f">={PYTHON_DEFAULT_VERSION}",
}
if "project" not in self.pyproject_toml.config:
self.pyproject_toml.config["project"] = {}
for key, value in project.items():
if key not in self.pyproject_toml.config["project"]:
self.pyproject_toml.config["project"][key] = value
self.pyproject_toml.config["project"] = dict_sort(self.pyproject_toml.config["project"])
if original_project != self.pyproject_toml.config["project"]:
with self.pyproject_toml.file.open("w") as f:
with pipmetapathfinder():
import tomlkit
tomlkit.dump(self.pyproject_toml.config, f)
self.info(f"{self.write.__name__}: {self.pyproject_toml.file}")
if self.docsdir:
imp = f"import {NODEPS_PROJECT_NAME}.__main__" if self.name == NODEPS_PROJECT_NAME else ""
conf = f"""import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
{imp}
project = "{github["name"]}"
author = "{AUTHOR}"
# noinspection PyShadowingBuiltins
copyright = "{datetime.datetime.now().year}, {AUTHOR}"
extensions = [
"myst_parser",
"sphinx.ext.autodoc",
"sphinx.ext.autosectionlabel",
"sphinx.ext.extlinks",
"sphinx.ext.napoleon",
"sphinx.ext.viewcode",
"sphinx_click",
"sphinx.ext.intersphinx",
]
autoclass_content = "both"
autodoc_default_options = {{"members": True, "member-order": "bysource",
"undoc-members": True, "show-inheritance": True}}
autodoc_typehints = "description"
autosectionlabel_prefix_document = True
html_theme = "furo"
html_title, html_last_updated_fmt = "{self.name} docs", "%Y-%m-%dT%H:%M:%S"
inheritance_alias = {{}}
nitpicky = True
nitpick_ignore = [('py:class', '*')]
toc_object_entries = True
toc_object_entries_show_parents = "all"
pygments_style, pygments_dark_style = "sphinx", "monokai"
extlinks = {{
"issue": ("https://github.com/{GIT}/{self.name}/issues/%s", "#%s"),
"pull": ("https://github.com/{GIT}/{self.name}/pull/%s", "PR #%s"),
"user": ("https://github.com/%s", "@%s"),
}}
intersphinx_mapping = {{
"python": ("https://docs.python.org/3", None),
"packaging": ("https://packaging.pypa.io/en/latest", None),
}}
""" # noqa: DTZ005
file = self.docsdir / "conf.py"
original = file.read_text() if file.is_file() else ""
if original != conf:
file.write_text(conf)
self.info(f"{self.write.__name__}: {file}")
requirements = """furo >=2023.9.10, <2024
linkify-it-py >=2.0.2, <3
myst-parser >=2.0.0, <3
sphinx >=7.2.6, <8
sphinx-autobuild >=2021.3.14, <2022
sphinx-click >=5.0.1, <6
sphinx_autodoc_typehints
sphinxcontrib-napoleon >=0.7, <1
typer[all] >= 0.9, <1
"""
file = self.docsdir / "requirements.txt"
original = file.read_text() if file.is_file() else ""
if original != requirements:
file.write_text(requirements)
self.info(f"{self.write.__name__}: {file}")
reference = f"""# Reference
## {self.name}
```{{eval-rst}}
.. automodule:: {self.name}
:members:
```
"""
file = self.docsdir / "reference.md"
original = file.read_text() if file.is_file() else ""
if original != reference:
file.write_text(reference)
self.info(f"{self.write.__name__}: {file}")