import os
from projspec.artifact.installable import Wheel
from projspec.artifact.process import Process
from projspec.content.environment import Environment, Precision, Stack
from projspec.content.executable import Command
from projspec.content.package import PythonPackage
from projspec.proj.base import ProjectSpec
from projspec.utils import AttrDict
[docs]
class PythonCode(ProjectSpec):
"""Code directly importable by python
This applies to directories with __init__.py (i.e., not isolated .py files,
or eggs). Could include .zip in theory.
Such a structure does not declare any envs, deps, etc. It contains
nothing interesting _except_ code.
A package is executable if it contains a ``__main__.py`` file.
"""
icon = "🐍"
spec_doc = "https://docs.python.org/3/reference/import.html#regular-packages"
def match(self) -> bool:
return "__init__.py" in self.proj.basenames
def parse(self):
arts = AttrDict()
if "__main__.py" in self.proj.basenames:
arts["process"] = AttrDict(
main=Process(proj=self.proj, cmd=["python", "__main__.py"])
)
self._artifacts = arts
out = AttrDict(
PythonPackage(
proj=self.proj,
package_name=self.proj.url.rsplit("/", 1)[-1],
)
)
if arts:
art = arts["process"]["main"]
out["command"] = AttrDict(main=Command(proj=self.proj, cmd=art.cmd))
self._contents = out
@staticmethod
def _create(path: str) -> None:
open(f"{path}/__init__.py", "w").close()
[docs]
class PythonLibrary(ProjectSpec):
"""Complete buildable python project
Defined by the existence of pyproject.toml or setup.py.
"""
icon = "📦"
# setup.py never had a spec
spec_doc = "https://packaging.python.org/en/latest/specifications/pyproject-toml/"
def match(self) -> bool:
return not {"pyproject.toml", "setup.py"}.isdisjoint(self.proj.basenames)
def parse(self):
arts = AttrDict()
if "build-system" in self.proj.pyproject:
# should imply that "python -m build" can run
# With `--wheel`?
arts["wheel"] = Wheel(proj=self.proj, cmd=["python", "-m", "build"])
elif "setup.py" in self.proj.basenames:
arts["wheel"] = Wheel(
proj=self.proj,
cmd=["python", f"{self.proj.url}/setup.py", "bdist_wheel"],
)
self._artifacts = arts
conts = AttrDict()
# not attempting to parse setup.py, although most commonly a subdirectory with
# the same name as the repo is the python package
proj = self.proj.pyproject.get("project", None)
env = AttrDict()
if proj is not None:
conts["python_package"] = PythonPackage(
proj=self.proj, package_name=proj["name"]
)
py = (
[f"python {proj['requires-python']}"]
if "requires-python" in proj
else []
)
if "dependencies" in proj:
env["default"] = Environment(
proj=self.proj,
precision=Precision.SPEC,
stack=Stack.PIP,
packages=proj["dependencies"] + py,
channels=[],
)
if "optional-dependencies" in proj:
# these are advertised in the built package, even when
# called "test" or "dev".
for name, deps in proj["optional-dependencies"].items():
env[name] = Environment(
proj=self.proj,
precision=Precision.SPEC,
stack=Stack.PIP,
packages=deps + py,
channels=[],
)
for x in ("scripts", "gui-scripts"):
if x in proj:
cmd = AttrDict()
for name, script in proj["scripts"].items():
mod, func = script.rsplit(":", 1)
c = f"import sys; from {mod} import {func}; sys.exit({func}())"
cmd[name] = Command(
proj=self.proj,
cmd=["python", "-c", c],
)
conts["command"] = cmd
if "dependency-groups" in self.proj.pyproject:
# these are means for local envs with the library source, you
# don't use with `pip install package[extras]`
env.update(
{
k: Environment(
proj=self.proj,
precision=Precision.SPEC,
stack=Stack.PIP,
packages=v,
channels=[],
)
for k, v in _resolve_groups(
self.proj.pyproject["dependency-groups"]
).items()
}
)
if "default" not in env and "requirements.txt" in self.proj.basenames:
fn = f"{self.proj.url}/requirements.txt"
with self.proj.fs.open(fn, "rt") as f:
lines = f.readlines()
env["default"] = Environment(
proj=self.proj,
precision=Precision.SPEC,
stack=Stack.PIP,
packages=[l.rstrip() for l in lines if l and "#" not in l],
channels=[],
)
if env:
conts["environment"] = env
# + venv artifact
# TODO: pick keys to add to DescriptiveMetadata
self._contents = conts
@staticmethod
def _create(path: str, name: str | None = None) -> None:
with open(f"{path}/pyproject.toml", "w") as f:
# adapted from:
# https://packaging.python.org/en/latest/guides/writing-pyproject-toml
f.write(
"""
[build-system]
requires = ["setuptools >= 77.0.3"]
build-backend = "setuptools.build_meta"
[project]
name = "spam"
version = "0.0.1"
dependencies = [
"click",
]
requires-python = ">=3.10"
maintainers = [
{name = "You", email = "you@example.com"}
]
description = "Lovely Spam! Wonderful Spam!"
readme = "README.rst"
license = "MIT"
license-files = ["LICEN[CS]E.*"]
keywords = []
classifiers = [
"Programming Language :: Python"
]
[project.optional-dependencies]
test = ["pytest"]
[project.urls]
Homepage = "https://example.com"
Repository = "https://github.com/me/spam.git"
[project.scripts]
spam-cli = "spam:main_cli"
"""
)
os.makedirs(f"{path}/src/spam", exist_ok=True)
open(f"{path}/src/spam/__init__.py", "w")
open(
f"{path}/README.rst", "w"
).close() # https://spdx.org/licenses/MIT.html
open(f"{path}/src/spam/main_cli.py", "w").write("print('Hello World!')\n")
def _resolve_groups(dep) -> dict[str, list[str]]:
# A simplified version of
# https://packaging.python.org/en/latest/specifications/dependency-groups/
# #reference-implementation
# only resolves groups in order.
out = {}
for name, deps in dep.items():
out[name] = []
for d in deps:
if isinstance(d, str):
out[name].append(d)
elif isinstance(d, dict) and list(d) == ["include-group"]:
out[name].extend(out[d["include-group"]])
return out