Skip to content

Commit 040da25

Browse files
authored
Merge pull request #8 from martindurant/serde
Make everything reducable to dictionaries
2 parents 69ae26c + 769ff75 commit 040da25

19 files changed

Lines changed: 345 additions & 193 deletions

src/projspec/artifact/base.py

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,9 @@
1212

1313

1414
class BaseArtifact:
15-
def __init__(
16-
self,
17-
proj: Project,
18-
requires: list | None = None,
19-
cmd: list[str] | None = None,
20-
**kw,
21-
):
15+
def __init__(self, proj: Project, cmd: list[str] | None = None):
2216
self.proj = proj
23-
self.requires = requires or []
2417
self.cmd = cmd
25-
self.__dict__.update(kw)
2618
self.proc = None
2719

2820
def _is_clean(self) -> bool:
@@ -58,17 +50,11 @@ def make(self, *args, **kwargs):
5850
def _make(self, *args, **kwargs):
5951
subprocess.check_call(self.cmd, cwd=self.proj.url, **kwargs)
6052

61-
def remake(self, reqs=False):
53+
def remake(self):
6254
"""Recreate the artifact and any runtime it depends on"""
63-
if reqs:
64-
self.clean_req()
6555
self.clean()
6656
self.make()
6757

68-
def clean_req(self):
69-
for req in self.requires:
70-
req.clean()
71-
7258
def clean(self):
7359
"""Remove artifact"""
7460
# this default implementation leaves nothing to clean
@@ -91,6 +77,22 @@ def __init_subclass__(cls, **kwargs):
9177
def snake_name(cls):
9278
return camel_to_snake(cls.__name__)
9379

80+
def to_dict(self, compact=True):
81+
"""Distil the instance to JSON compatible dict
82+
83+
compact: if True, will produce condensed output, perhaps justa string.
84+
"""
85+
if compact:
86+
return self._repr2()
87+
dic = {
88+
k: v
89+
for k, v in self.__dict__.items()
90+
if not k.startswith("_") and k not in ("proj", "proc")
91+
}
92+
dic["klass"] = ["artifact", self.snake_name()]
93+
dic["proc"] = None
94+
return dic
95+
9496

9597
def get_artifact_cls(name: str) -> type[BaseArtifact]:
9698
"""Find an artifact class by snake-case name."""

src/projspec/content/base.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,21 @@ def __init_subclass__(cls, **kwargs):
3030
def snake_name(cls):
3131
return camel_to_snake(cls.__name__)
3232

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
47+
3348

3449
def get_content_cls(name: str) -> type[BaseContent]:
3550
"""Find a content class by snake-case name."""

src/projspec/proj/__init__.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
from .base import Project, ProjectSpec
1+
from .base import Project, ProjectSpec, get_projspec_class
22
from .conda_package import CondaRecipe, RattlerRecipe
33
from .conda_project import CondaProject
44
from .documentation import RTD, MDBook
@@ -8,9 +8,10 @@
88
from .pyscript import PyScript
99
from .python_code import PythonCode, PythonLibrary
1010
from .rust import Rust, RustPython
11-
from .uv import UV
11+
from .uv import Uv
1212

1313
__all__ = [
14+
"get_projspec_class",
1415
"Project",
1516
"ProjectSpec",
1617
"CondaRecipe",
@@ -26,5 +27,5 @@
2627
"RTD",
2728
"Rust",
2829
"RustPython",
29-
"UV",
30+
"Uv",
3031
]

src/projspec/proj/base.py

Lines changed: 57 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,16 @@
66
import fsspec.implementations.local
77
import toml
88

9-
from projspec.utils import AttrDict, IndentDumper, camel_to_snake, flatten
9+
from projspec.utils import (
10+
AttrDict,
11+
IndentDumper,
12+
PickleableTomlDecoder,
13+
camel_to_snake,
14+
flatten,
15+
)
1016

1117
logger = logging.getLogger("projspec")
12-
registry = set()
18+
registry = {}
1319
default_excludes = {
1420
".venv", # venv, pipenv, uv
1521
".pixi",
@@ -32,6 +38,9 @@ def __init__(
3238
):
3339
if fs is None:
3440
fs, path = fsspec.url_to_fs(path, **(storage_options or {}))
41+
else:
42+
storage_options = fs.storage_options
43+
self.storage_options = storage_options or {}
3544
self.fs = fs
3645
self.url = path
3746
self.specs = AttrDict()
@@ -60,7 +69,8 @@ def resolve(
6069
"""
6170
fullpath = "/".join([self.url, subpath]) if subpath else self.url
6271
# sorting to ensure consistency
63-
for cls in sorted(registry, key=str):
72+
for name in sorted(registry):
73+
cls = registry[name]
6474
try:
6575
logger.debug("resolving %s as %s", fullpath, cls)
6676
name = cls.__name__
@@ -142,7 +152,7 @@ def pyproject(self):
142152
if "pyproject.toml" in self.basenames:
143153
try:
144154
with self.fs.open(self.basenames["pyproject.toml"], "rt") as f:
145-
return toml.load(f)
155+
return toml.load(f, decoder=PickleableTomlDecoder())
146156
except (OSError, ValueError, TypeError):
147157
# debug/warn?
148158
pass
@@ -172,9 +182,27 @@ def __contains__(self, item) -> bool:
172182
item in _ for _ in self.children.values()
173183
)
174184

175-
def to_dict(self) -> dict:
176-
dic = AttrDict(specs=self.specs, children=self.children)
177-
return dic.to_dict()
185+
def to_dict(self, compact=True) -> dict:
186+
dic = AttrDict(
187+
specs=self.specs,
188+
children=self.children,
189+
url=self.url,
190+
storage_options=self.storage_options,
191+
)
192+
dic["klass"] = "project"
193+
return dic.to_dict(compact=compact)
194+
195+
@staticmethod
196+
def from_dict(dic):
197+
from projspec.utils import from_dict
198+
199+
proj = object.__new__(Project)
200+
proj.specs = from_dict(dic["specs"], proj)
201+
proj.children = from_dict(dic["children"], proj)
202+
proj.url = dic["url"]
203+
proj.storage_options = dic["storage_options"]
204+
proj.fs, _ = fsspec.url_to_fs(proj.url, **proj.storage_options)
205+
return proj
178206

179207

180208
class ProjectSpec:
@@ -186,8 +214,8 @@ class ProjectSpec:
186214

187215
spec_doc = "" # URL to prose about this spec
188216

189-
def __init__(self, root: Project, subpath: str = ""):
190-
self.root = root
217+
def __init__(self, proj: Project, subpath: str = ""):
218+
self.proj = proj
191219
self.subpath = subpath # not used yet
192220
self._contents = AttrDict()
193221
self._artifacts = AttrDict()
@@ -198,9 +226,9 @@ def __init__(self, root: Project, subpath: str = ""):
198226
def path(self) -> str:
199227
"""Location of this project spec"""
200228
return (
201-
self.root.url + "/" + self.subpath
229+
self.proj.url + "/" + self.subpath
202230
if self.subpath
203-
else self.root.url
231+
else self.proj.url
204232
)
205233

206234
def match(self) -> bool:
@@ -241,7 +269,7 @@ def clean(self) -> None:
241269

242270
@classmethod
243271
def __init_subclass__(cls, **kwargs):
244-
registry.add(cls)
272+
registry[camel_to_snake(cls.__name__)] = cls
245273

246274
def __repr__(self):
247275
import yaml
@@ -253,6 +281,20 @@ def __repr__(self):
253281
base += f"\nArtifacts:\n{yaml.dump(self.artifacts.to_dict(), Dumper=IndentDumper).rstrip()}\n"
254282
return base
255283

256-
def to_dict(self) -> dict:
257-
dic = AttrDict(contents=self.contents, artifacts=self.artifacts)
258-
return dic.to_dict()
284+
def to_dict(self, compact=True) -> dict:
285+
dic = AttrDict(
286+
_contents=self.contents,
287+
_artifacts=self.artifacts,
288+
subpath=self.subpath,
289+
klass=["projspec", self.snake_name()],
290+
)
291+
return dic.to_dict(compact=compact)
292+
293+
@classmethod
294+
def snake_name(cls) -> str:
295+
"""Convert a project name to snake-case"""
296+
return camel_to_snake(cls.__name__)
297+
298+
299+
def get_projspec_class(name: str) -> type:
300+
return registry[name]

src/projspec/proj/briefcase.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,4 @@ class Briefcase(ProjectSpec):
55
spec_doc = "https://briefcase.readthedocs.io/en/stable/reference/configuration.html"
66

77
def match(self) -> bool:
8-
return "briefcase" in self.root.pyproject.get("tool", {})
8+
return "briefcase" in self.proj.pyproject.get("tool", {})

src/projspec/proj/conda_package.py

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class CondaRecipe(ProjectSpec):
1212

1313
def match(self) -> bool:
1414
return not {"meta.yaml", "meta.yml", "conda.yaml"}.isdisjoint(
15-
self.root.basenames
15+
self.proj.basenames
1616
)
1717

1818
def parse(self) -> None:
@@ -21,9 +21,9 @@ def parse(self) -> None:
2121

2222
meta = None
2323
for fn in ("meta.yaml", "meta.yml", "conda.yaml"):
24-
if fn in self.root.basenames:
24+
if fn in self.proj.basenames:
2525
try:
26-
with self.root.fs.open(self.root.basenames[fn], "rb") as f:
26+
with self.proj.fs.open(self.proj.basenames[fn], "rb") as f:
2727
meta0 = _yaml_no_jinja(f)
2828
# TODO: multiple output recipe
2929
if "package" in meta0:
@@ -38,14 +38,14 @@ def parse(self) -> None:
3838
pass
3939
if meta is None:
4040
raise ValueError
41-
art = CondaPackage(proj=self.root, cmd=["conda-build", self.root.url])
42-
self._artifacts = AttrDict(**{meta["package"]["name"]: art})
41+
art = CondaPackage(proj=self.proj, cmd=["conda-build", self.proj.url])
42+
self._artifacts = AttrDict(conda_package=art)
4343
# TODO: read envs from "outputs" like for Rattler, below?
4444
self._contents = AttrDict(
4545
environment=AttrDict(
4646
{
4747
k: Environment(
48-
proj=self.root,
48+
proj=self.proj,
4949
artifacts={art},
5050
packages=v,
5151
stack=Stack.CONDA,
@@ -65,19 +65,19 @@ class RattlerRecipe(CondaRecipe):
6565
# conda recipes are also valid for rattler if they don't have complex jinja.
6666

6767
def match(self) -> bool:
68-
return "recipe.yaml" in self.root.basenames
68+
return "recipe.yaml" in self.proj.basenames
6969

7070
def parse(self) -> None:
7171
from projspec.artifact.installable import CondaPackage
7272
from projspec.content.environment import Environment, Precision, Stack
7373

74-
if "recipe.yaml" in self.root.basenames:
75-
with self.root.fs.open(
76-
self.root.basenames["recipe.yaml"], "rb"
74+
if "recipe.yaml" in self.proj.basenames:
75+
with self.proj.fs.open(
76+
self.proj.basenames["recipe.yaml"], "rb"
7777
) as f:
7878
meta = _yaml_no_jinja(f)
79-
elif "meta.yaml" in self.root.basenames:
80-
with self.root.fs.open(self.root.basenames["meta.yaml"], "rb") as f:
79+
elif "meta.yaml" in self.proj.basenames:
80+
with self.proj.fs.open(self.proj.basenames["meta.yaml"], "rb") as f:
8181
meta = _yaml_no_jinja(f)
8282
else:
8383
raise ValueError
@@ -86,9 +86,9 @@ def parse(self) -> None:
8686
"rattler-build",
8787
"build",
8888
"-r",
89-
self.root.url,
89+
self.proj.url,
9090
"--output-dir",
91-
f"{self.root.url}/output",
91+
f"{self.proj.url}/output",
9292
]
9393
name = next(
9494
filter(
@@ -101,10 +101,10 @@ def parse(self) -> None:
101101
)
102102

103103
path = (
104-
f"{self.root.url}/output/{name}" if self.root.is_local() else None
104+
f"{self.proj.url}/output/{name}" if self.proj.is_local() else None
105105
)
106106
art = CondaPackage(
107-
proj=self.root,
107+
proj=self.proj,
108108
cmd=cmd,
109109
path=path,
110110
name=name,
@@ -125,7 +125,7 @@ def parse(self) -> None:
125125
environment=AttrDict(
126126
{
127127
k: Environment(
128-
proj=self.root,
128+
proj=self.proj,
129129
artifacts={art},
130130
packages=v,
131131
stack=Stack.CONDA,

0 commit comments

Comments
 (0)