projspec 0.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
projspec/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from projspec._version import __version__ # noqa: F401
2
+ from projspec.proj import Project, ProjectSpec
3
+ import projspec.content
4
+ import projspec.artifact
5
+ from projspec.utils import get_cls
6
+
7
+ __all__ = ["Project", "ProjectSpec", "get_cls"]
projspec/__main__.py ADDED
@@ -0,0 +1,57 @@
1
+ #!/usr/bin/env python
2
+ """Simple example executable for this library"""
3
+
4
+ import json
5
+
6
+ import click
7
+
8
+ import projspec.proj
9
+
10
+ # TODO: allow subcommands to execute artifacts, list known types and set config
11
+
12
+
13
+ @click.command()
14
+ @click.option(
15
+ "--types",
16
+ default="ALL",
17
+ help='Type names to scan for (comma-separated list in camel or snake case); defaults to "ALL"',
18
+ )
19
+ @click.argument("path", default=".")
20
+ @click.option("--walk", is_flag=True, help="To descend into all child directories")
21
+ @click.option("--summary", is_flag=True, help="Show abbreviated output")
22
+ @click.option(
23
+ "--make", help="(Re)Create the first artifact found matching this type name"
24
+ )
25
+ @click.option(
26
+ "--storage_options",
27
+ default="",
28
+ help="storage options dict for the given URL, as JSON",
29
+ )
30
+ def main(path, types, walk, summary, make, storage_options):
31
+ if types in {"ALL", ""}:
32
+ types = None
33
+ else:
34
+ types = types.split(",")
35
+ if storage_options:
36
+ storage_options = json.loads(storage_options)
37
+ else:
38
+ storage_options = None
39
+ proj = projspec.Project(
40
+ path, storage_options=storage_options, types=types, walk=walk
41
+ )
42
+ if make:
43
+ art: projspec.artifact.BaseArtifact
44
+ for art in proj.artifacts:
45
+ if art.snake_name() == projspec.utils.camel_to_snake(make):
46
+ print("Launching:", art)
47
+ art.remake()
48
+ return
49
+ print("No such artifact found")
50
+ elif summary:
51
+ print(proj.text_summary())
52
+ else:
53
+ print(proj)
54
+
55
+
56
+ if __name__ == "__main__":
57
+ main()
projspec/_version.py ADDED
@@ -0,0 +1,34 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = [
5
+ "__version__",
6
+ "__version_tuple__",
7
+ "version",
8
+ "version_tuple",
9
+ "__commit_id__",
10
+ "commit_id",
11
+ ]
12
+
13
+ TYPE_CHECKING = False
14
+ if TYPE_CHECKING:
15
+ from typing import Tuple
16
+ from typing import Union
17
+
18
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
19
+ COMMIT_ID = Union[str, None]
20
+ else:
21
+ VERSION_TUPLE = object
22
+ COMMIT_ID = object
23
+
24
+ version: str
25
+ __version__: str
26
+ __version_tuple__: VERSION_TUPLE
27
+ version_tuple: VERSION_TUPLE
28
+ commit_id: COMMIT_ID
29
+ __commit_id__: COMMIT_ID
30
+
31
+ __version__ = version = '0.0.2'
32
+ __version_tuple__ = version_tuple = (0, 0, 2)
33
+
34
+ __commit_id__ = commit_id = None
@@ -0,0 +1,18 @@
1
+ """Things that a project can do or make"""
2
+
3
+ from projspec.artifact.base import BaseArtifact, FileArtifact
4
+ from projspec.artifact.installable import CondaPackage, Wheel
5
+ from projspec.artifact.process import Process
6
+ from projspec.artifact.python_env import EnvPack, CondaEnv, VirtualEnv, LockFile
7
+
8
+ __all__ = [
9
+ "BaseArtifact",
10
+ "FileArtifact",
11
+ "CondaPackage",
12
+ "Wheel",
13
+ "Process",
14
+ "EnvPack",
15
+ "CondaEnv",
16
+ "VirtualEnv",
17
+ "LockFile",
18
+ ]
@@ -0,0 +1,114 @@
1
+ import logging
2
+ import subprocess
3
+ from typing import Literal
4
+
5
+ import fsspec.implementations.local
6
+
7
+ from projspec.proj import Project
8
+ from projspec.utils import camel_to_snake, is_installed
9
+
10
+ logger = logging.getLogger("projspec")
11
+ registry = {}
12
+
13
+
14
+ class BaseArtifact:
15
+ def __init__(self, proj: Project, cmd: list[str] | None = None):
16
+ self.proj = proj
17
+ self.cmd = cmd
18
+ self.proc = None
19
+
20
+ def _is_clean(self) -> bool:
21
+ return self.proc is None # in general, more complex
22
+
23
+ def _is_done(self) -> bool:
24
+ return self.proc is not None # in general, more complex
25
+
26
+ def _check_runner(self):
27
+ return self.cmd[0] in is_installed
28
+
29
+ @property
30
+ def state(self) -> Literal["clean", "done", "pending"]:
31
+ if self._is_clean():
32
+ return "clean"
33
+ elif self._is_done():
34
+ return "done"
35
+ else:
36
+ return "pending"
37
+
38
+ def make(self, *args, **kwargs):
39
+ """Create the artifact and any runtime it depends on"""
40
+ if not isinstance(self.proj.fs, fsspec.implementations.local.LocalFileSystem):
41
+ # Later, will implement download-and-make, although some tools
42
+ # can already do this themselves.
43
+ raise RuntimeError("Can't run local command on remote project")
44
+ logger.debug(" ".join(self.cmd))
45
+ # this default implementation does not store any state
46
+ self._make(*args, **kwargs)
47
+
48
+ def _make(self, *args, **kwargs):
49
+ subprocess.check_call(self.cmd, cwd=self.proj.url, **kwargs)
50
+
51
+ def remake(self):
52
+ """Recreate the artifact and any runtime it depends on"""
53
+ self.clean()
54
+ self.make()
55
+
56
+ def clean(self):
57
+ """Remove artifact"""
58
+ # this default implementation leaves nothing to clean
59
+ pass
60
+
61
+ def __repr__(self):
62
+ return f"{type(self).__name__}, '{' '.join(self.cmd)}', {self.state}"
63
+
64
+ def _repr2(self):
65
+ return f"{' '.join(self.cmd)}, {self.state}"
66
+
67
+ @classmethod
68
+ def __init_subclass__(cls, **kwargs):
69
+ sn = cls.snake_name()
70
+ if sn in registry:
71
+ raise RuntimeError()
72
+ registry[sn] = cls
73
+
74
+ @classmethod
75
+ def snake_name(cls):
76
+ return camel_to_snake(cls.__name__)
77
+
78
+ def to_dict(self, compact=True):
79
+ """Distil the instance to JSON compatible dict
80
+
81
+ compact: if True, will produce condensed output, perhaps justa string.
82
+ """
83
+ if compact:
84
+ return self._repr2()
85
+ dic = {
86
+ k: v
87
+ for k, v in self.__dict__.items()
88
+ if not k.startswith("_") and k not in ("proj", "proc")
89
+ }
90
+ dic["klass"] = ["artifact", self.snake_name()]
91
+ dic["proc"] = None
92
+ return dic
93
+
94
+
95
+ def get_cls(name: str) -> type[BaseArtifact]:
96
+ """Find an artifact class by snake-case name."""
97
+ return registry[name]
98
+
99
+
100
+ class FileArtifact(BaseArtifact):
101
+ """Specialised artifacts, where the output is one or more files"""
102
+
103
+ # TODO: account for outputs to a directory/glob pattern, so we can
104
+ # apply to wheel; or unknown output location, e.g., conda-build.
105
+
106
+ def __init__(self, proj: Project, fn: str, **kw):
107
+ self.fn = fn
108
+ super().__init__(proj, **kw)
109
+
110
+ def _is_done(self) -> bool:
111
+ return self.proj.fs.exists(self.fn)
112
+
113
+ def _is_clean(self) -> bool:
114
+ return not self.proj.fs.exists(self.fn)
@@ -0,0 +1,68 @@
1
+ import logging
2
+ import os.path
3
+ import subprocess
4
+
5
+ from projspec.artifact import BaseArtifact
6
+
7
+ logger = logging.getLogger("projspec")
8
+
9
+
10
+ class Wheel(BaseArtifact):
11
+ """An installable python wheel file
12
+
13
+ Note that in general there may be a set of wheels for different platforms.
14
+ The actual name of the wheel file depends on platform, vcs config
15
+ and maybe other factors. We just check if the dist/ directory is
16
+ populated.
17
+
18
+ This output is intended to be _local_ - pushing to a remote location (e.g., pypi)
19
+ is call publishing.
20
+ """
21
+
22
+ def _is_done(self) -> bool:
23
+ return True
24
+
25
+ def _is_clean(self) -> bool:
26
+ files = self.proj.fs.glob(f"{self.proj.url}/dist/*.whl")
27
+ return len(files) == 0
28
+
29
+ def clean(self):
30
+ files = self.proj.fs.glob(f"{self.proj.url}/dist/*.whl")
31
+ self.proj.fs.rm(files)
32
+
33
+
34
+ class CondaPackage(BaseArtifact):
35
+ """An installable python wheel file
36
+
37
+ Note that in general, there may be a set of wheels for different platforms.
38
+ The actual name of the wheel file depends on the platform, vcs config
39
+ and maybe other factors. We just check if the dist/ directory is
40
+ populated.
41
+
42
+ This output is intended to be _local_ - pushing to a remote location (e.g., pypi)
43
+ is call publishing.
44
+ """
45
+
46
+ def __init__(self, path=None, name=None, **kwargs):
47
+ super().__init__(**kwargs)
48
+ self.path: str | None = path
49
+ self.name = name
50
+
51
+ def _make(self, *args, **kwargs):
52
+ import re
53
+
54
+ logger.debug(" ".join(self.cmd))
55
+ out = subprocess.check_output(self.cmd).decode("utf-8")
56
+ if fn := re.match(r"'(.*?\.conda)'\n", out):
57
+ if os.path.exists(fn.group(1)):
58
+ self.path = fn.group(1)
59
+
60
+ def _is_done(self) -> bool:
61
+ return True
62
+
63
+ def _is_clean(self) -> bool:
64
+ return self.path is None or not self.proj.fs.glob(self.path)
65
+
66
+ def clean(self):
67
+ if self.path is not None:
68
+ self.proj.fs.rm(self.path)
@@ -0,0 +1,15 @@
1
+ from projspec import Project
2
+ from projspec.artifact import BaseArtifact
3
+
4
+ # ruff, isort, mypy ...
5
+
6
+
7
+ class PreCommit(BaseArtifact):
8
+ """Typically used as a git hook, this lists a set of linters that a project uses."""
9
+
10
+ # recognised by the presence o .pre-commit-config.yaml, but we don't need
11
+ # to parse it in order to run.
12
+
13
+ def __init__(self, proj: Project, cmd=None):
14
+ # ignore cmd: this should always be the same
15
+ super().__init__(proj, cmd=["pre-commit", "run", "-a"])
@@ -0,0 +1,25 @@
1
+ import subprocess
2
+
3
+ from projspec.artifact import BaseArtifact
4
+
5
+
6
+ class Process(BaseArtifact):
7
+ """A simple process where we know nothing about what it does, only if it's running.
8
+
9
+ Can include batch jobs and long-running services.
10
+ """
11
+
12
+ def _make(self):
13
+ if self.proc is None:
14
+ self.proc = subprocess.Popen(self.cmd, **self.kw)
15
+
16
+ def _is_done(self) -> bool:
17
+ return self.proc is not None and self.proc.poll() is None
18
+
19
+ def clean(
20
+ self,
21
+ ):
22
+ if self.proc is not None:
23
+ self.proc.terminate()
24
+ self.proc.wait()
25
+ self.proc = None
@@ -0,0 +1,50 @@
1
+ """Python runtimes
2
+
3
+ Note that for actually running python processes. There is also an implicit
4
+ runtime from either the env that the process is running in (i.e., the PATH),
5
+ or sys.executable.
6
+ """
7
+
8
+ import json
9
+ import subprocess
10
+ from functools import cache
11
+
12
+ from projspec.artifact import FileArtifact
13
+
14
+
15
+ class CondaEnv(FileArtifact):
16
+ """Path to a project conda-built env
17
+
18
+ Contains both python itself and any other binaries, as well as linked
19
+ libraries.
20
+
21
+ In the case of a project having an environment.yaml with a named output,
22
+ the path may be outside the project tree.
23
+ """
24
+
25
+ @staticmethod
26
+ @cache
27
+ def envs() -> list[str]:
28
+ """Global conda env root paths"""
29
+ # pixi also has global envs
30
+ out = subprocess.check_output(["conda", "env", "list", "--json"])
31
+ return json.loads(out.decode())["envs"]
32
+
33
+
34
+ class VirtualEnv(FileArtifact):
35
+ """Path to a project virtual environment
36
+
37
+ Some tools like pipenv put these environments in a global location.
38
+ """
39
+
40
+
41
+ class EnvPack(FileArtifact):
42
+ """Archival form of a python environment
43
+
44
+ - conda-pack: https://conda.github.io/conda-pack/
45
+ - pixi-pack: https://pixi.sh/latest/deployment/pixi_pack/
46
+ """
47
+
48
+
49
+ class LockFile(FileArtifact):
50
+ """File containing exact environment specification"""
@@ -0,0 +1,25 @@
1
+ from projspec.content.base import BaseContent
2
+ from projspec.content.data import FrictionlessData, IntakeCatalog
3
+ from projspec.content.env_var import EnvironmentVariables
4
+ from projspec.content.environment import Environment, Stack, Precision
5
+ from projspec.content.executable import Command
6
+ from projspec.content.license import License
7
+
8
+ # from projspec.content.linter
9
+ from projspec.content.metadata import DescriptiveMetadata
10
+ from projspec.content.package import PythonPackage
11
+
12
+
13
+ __all__ = [
14
+ "BaseContent",
15
+ "FrictionlessData",
16
+ "IntakeCatalog",
17
+ "EnvironmentVariables",
18
+ "Command",
19
+ "License",
20
+ "DescriptiveMetadata",
21
+ "PythonPackage",
22
+ "Environment",
23
+ "Stack",
24
+ "Precision",
25
+ ]
@@ -0,0 +1,46 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from projspec.artifact import BaseArtifact
4
+ from projspec.proj.base import Project
5
+ from projspec.utils import Enum, camel_to_snake
6
+
7
+ registry = {}
8
+
9
+
10
+ @dataclass
11
+ class BaseContent:
12
+ proj: Project = field(repr=False)
13
+ artifacts: set[BaseArtifact] = field(repr=False)
14
+
15
+ def _repr2(self):
16
+ return {
17
+ k: (v.name if isinstance(v, Enum) else v)
18
+ for k, v in self.__dict__.items()
19
+ if not k.startswith("_") and k not in ("proj", "artifacts")
20
+ }
21
+
22
+ @classmethod
23
+ def __init_subclass__(cls, **kwargs):
24
+ sn = cls.snake_name()
25
+ if sn in registry:
26
+ raise RuntimeError()
27
+ registry[sn] = cls
28
+
29
+ @classmethod
30
+ def snake_name(cls):
31
+ return camel_to_snake(cls.__name__)
32
+
33
+ def to_dict(self, compact=False):
34
+ if compact:
35
+ return self._repr2()
36
+ dic = {
37
+ k: getattr(self, k)
38
+ for k in self.__dataclass_fields__
39
+ if k not in ("proj", "artifacts")
40
+ }
41
+ dic["artifacts"] = []
42
+ dic["klass"] = ["content", self.snake_name()]
43
+ for k in list(dic):
44
+ if isinstance(dic[k], Enum):
45
+ dic[k] = dic[k].value
46
+ return dic
@@ -0,0 +1,24 @@
1
+ """Contents specifying datasets"""
2
+
3
+ from projspec.content import BaseContent
4
+
5
+
6
+ class FrictionlessData(BaseContent):
7
+ """A datapackage spec, as defined by frictionlessdata
8
+
9
+ This lists loadable tabular files with defined schema, typically from formats such as
10
+ JSON, CSV, and parquet.
11
+
12
+ See https://specs.frictionlessdata.io/data-package/
13
+ """
14
+
15
+ # typically in a datapackage.json spec
16
+
17
+
18
+ class IntakeCatalog(BaseContent):
19
+ """A catalog of data assets, including basic properties (location) and how to load/process them.
20
+
21
+ See https://intake.readthedocs.io/en/latest/
22
+ """
23
+
24
+ # typically in a catalog.yaml free-floating file
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from projspec.content.base import BaseContent
4
+
5
+
6
+ @dataclass
7
+ class EnvironmentVariables(BaseContent):
8
+ """A set of environment variable key/value pairs, typically used with new processes."""
9
+
10
+ variables: dict[str, str | None] = field(default_factory=dict)
@@ -0,0 +1,41 @@
1
+ from dataclasses import dataclass, field
2
+ from enum import auto
3
+
4
+ from projspec.content import BaseContent
5
+ from projspec.utils import Enum
6
+
7
+
8
+ class Stack(Enum):
9
+ """The type of environment by packaging tech"""
10
+
11
+ PIP = auto()
12
+ CONDA = auto()
13
+
14
+
15
+ class Precision(Enum):
16
+ """Type of environment definition by the amount of precision"""
17
+
18
+ # TODO: categories may be refined, e.g., whether items include architecture or hash
19
+ SPEC = auto()
20
+ LOCK = auto()
21
+
22
+
23
+ @dataclass
24
+ class Environment(BaseContent):
25
+ """Definition of a python runtime environment"""
26
+
27
+ stack: Stack
28
+ precision: Precision
29
+ packages: list[str]
30
+ # This may be empty for loose specs; may include endpoints or index URLs.
31
+ channels: list[str] = field(default_factory=list)
32
+
33
+ def _repr2(self):
34
+ out = {
35
+ k: (v.name if isinstance(v, Enum) else v)
36
+ for k, v in self.__dict__.items()
37
+ if not k.startswith("_") and k not in ("proj", "artifacts")
38
+ }
39
+ if not self.channels:
40
+ out.pop("channels", None)
41
+ return out
@@ -0,0 +1,15 @@
1
+ """Executable contents produce artifacts"""
2
+
3
+ from dataclasses import dataclass
4
+
5
+ from projspec.content import BaseContent
6
+
7
+
8
+ @dataclass
9
+ class Command(BaseContent):
10
+ """The simplest runnable thing; we don't know what it does/outputs."""
11
+
12
+ cmd: list[str] | str
13
+
14
+ def _repr2(self):
15
+ return " ".join(self.cmd) if isinstance(self.cmd, list) else self.cmd
@@ -0,0 +1,16 @@
1
+ from projspec.content import BaseContent
2
+
3
+
4
+ class License(BaseContent):
5
+ """A legal description of what the given project (code and other assets) can be used for.
6
+
7
+ This could be one of the typical open-source permissive licenses (see https://spdx.org/licenses/),
8
+ specified either just by its name or by a link. Some projects will have custom or restrictive
9
+ conditions on their replication and use.
10
+ """
11
+
12
+ # https://opensource.org/licenses
13
+
14
+ shortname: str # aka SPDX
15
+ fullname: str
16
+ url: str # relative in the project or remote HTTP
@@ -0,0 +1,9 @@
1
+ from dataclasses import field
2
+
3
+ from projspec.content import BaseContent
4
+
5
+
6
+ class DescriptiveMetadata(BaseContent):
7
+ """Miscellaneous descriptive information"""
8
+
9
+ meta: dict[str, str] = field(default_factory=dict)
@@ -0,0 +1,10 @@
1
+ from dataclasses import dataclass
2
+
3
+ from projspec.content import BaseContent
4
+
5
+
6
+ @dataclass
7
+ class PythonPackage(BaseContent):
8
+ """Importable python directory, i.e., containing an __init__.py file."""
9
+
10
+ package_name: str
projspec/html.py ADDED
@@ -0,0 +1,45 @@
1
+ def dict_to_html(data: dict, title="Data", open_level=2) -> str:
2
+ """
3
+ Convert a nested dictionary to expandable HTML using <details> tags.
4
+
5
+ Args:
6
+ data: The dictionary to convert
7
+ title: Title for the details element
8
+ open_level: whether to set elements as expanded; yes if > 0, and will
9
+ decrement for inner levels.
10
+
11
+ Returns:
12
+ String containing HTML with expandable details elements
13
+ """
14
+ # With help from Claude Sonnet 4.
15
+ if not isinstance(data, dict):
16
+ return f"<span>{data}</span>"
17
+
18
+ if not data:
19
+ return ""
20
+ open = "open" if open_level > 0 else "closed"
21
+
22
+ html = [
23
+ f'<details {open} style="margin-left: 20px; margin-bottom: 10px;"><summary style="cursor: pointer; color: #2c5aa0; padding: 5px;"><strong>{title}</strong></summary>'
24
+ ]
25
+
26
+ for key, value in data.items():
27
+ if isinstance(value, dict):
28
+ html.append(dict_to_html(value, key, open_level - 1))
29
+ elif isinstance(value, (list, tuple)):
30
+ html.append(
31
+ f'<details style="margin-left: 20px; margin-bottom: 10px;"><summary style="cursor: pointer; color: #2c5aa0; padding: 5px;"><strong>{key}</strong></summary>'
32
+ )
33
+ for i, item in enumerate(value):
34
+ if isinstance(item, dict):
35
+ html.append(dict_to_html(item, f"{key}[{i}]", open_level - 1))
36
+ else:
37
+ html.append(f'<div style=" margin: 5px 0;"> {item}</div>')
38
+ html.append("</details>")
39
+ else:
40
+ html.append(
41
+ f'<div style=" margin: 5px 0;"><strong>{key}:</strong> {value}</div>'
42
+ )
43
+
44
+ html.append("</details>")
45
+ return "".join(html)
@@ -0,0 +1,31 @@
1
+ from projspec.proj.base import ParseFailed, Project, ProjectSpec
2
+ from projspec.proj.conda_package import CondaRecipe, RattlerRecipe
3
+ from projspec.proj.conda_project import CondaProject
4
+ from projspec.proj.documentation import RTD, MDBook
5
+ from projspec.proj.git import GitRepo
6
+ from projspec.proj.pixi import Pixi
7
+ from projspec.proj.poetry import Poetry
8
+ from projspec.proj.pyscript import PyScript
9
+ from projspec.proj.python_code import PythonCode, PythonLibrary
10
+ from projspec.proj.rust import Rust, RustPython
11
+ from projspec.proj.uv import Uv
12
+
13
+ __all__ = [
14
+ "ParseFailed",
15
+ "Project",
16
+ "ProjectSpec",
17
+ "CondaRecipe",
18
+ "CondaProject",
19
+ "GitRepo",
20
+ "MDBook",
21
+ "Poetry",
22
+ "RattlerRecipe",
23
+ "Pixi",
24
+ "PyScript",
25
+ "PythonCode",
26
+ "PythonLibrary",
27
+ "RTD",
28
+ "Rust",
29
+ "RustPython",
30
+ "Uv",
31
+ ]