Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/source/api.rst
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,22 @@ Utilities
---------

.. autosummary::
library.ProjectLibrary
utils.AttrDict
utils.Enum
utils.IsInstalled
utils.get_cls
proj.base.ParseFailed
config.get_conf

.. autoclass:: library.ProjectLibrary
.. autoclass:: projspec.utils.AttrDict
.. autoclass:: projspec.utils.Enum
.. autofunction:: projspec.utils.get_cls
.. autoclass:: projspec.utils.IsInstalled
:members:
.. autoclass:: projspec.proj.base.ParseFailed
.. autofunction:: config.get_conf

.. raw:: html

Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,18 @@ dependencies = [
"fsspec >=2025.5.1",
"pyyaml",
"toml",
"fsspec",
"click",
"jinja2"
]

[project.optional-dependencies]
test = ["pytest", "pytest-cov"]
qt = ["pyqt>5,<6", "pyqtwebengin>5,<6"]

[project.scripts]
projspec = "projspec.__main__:main"
projspec-qt = "projspec.qtapp.main:main"

[tool.poetry.extras]
po_test = ["pytest"]
Expand All @@ -53,6 +56,11 @@ raw-options = {'version_scheme'='post-release'}
[tool.hatch.build.hooks.vcs]
version-file = "src/projspec/_version.py"

[tool.coverage.run]
omit = [
"src/projspec/qtapp/*.py"
]

[tool.ruff]
target-version = "py39"
exclude = [".tox", "build", "docs/source/conf.py", "projspec/_version"]
Expand Down
2 changes: 2 additions & 0 deletions src/projspec/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
from projspec.proj import Project, ProjectSpec
import projspec.content
import projspec.artifact
import projspec.config
import projspec.library
from projspec.utils import get_cls

__all__ = ["Project", "ProjectSpec", "get_cls"]
28 changes: 28 additions & 0 deletions src/projspec/config.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import json
import os

from typing import Any

conf: dict[str, dict[str, Any]] = {}
default_conf_dir = os.path.join(os.path.expanduser("~"), ".config/projspec")
conf_dir = os.environ.get("PROJSPEC_CONFIG_DIR", default_conf_dir)

defaults = {
# location of persisted project objects
"library_path": f"{conf_dir}/library.json",
}


def load_conf(path: str | None = None):
fn = f"{path or default_conf_dir}/projspec.json"
if os.path.exists(fn):
with open(fn) as f:
conf.update(json.load(f))


load_conf()


def get_conf(name: str):
"""Fetch the value of the given conf parameter from the current config or defaults"""
return conf[name] if name in conf else defaults[name]
74 changes: 74 additions & 0 deletions src/projspec/library.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import json
import os

import fsspec

from projspec.config import get_conf
from projspec.proj import Project


class ProjectLibrary:
"""Stores scanned project objects at a given path in JSON format

An instance of this library ``library`` is created on import.

In the future, alternative serialisations will be implemented.
"""

# TODO: support for remote libraries

def __init__(self, library_path: str | None = None, auto_save: bool = True):
self.path = library_path or get_conf("library_path")
self.load()
self.entries: dict[str, Project] = {}
self.auto_save = auto_save

def load(self):
"""Loads scanned project objects from JSON file"""
try:
with fsspec.open(self.path, "r") as f:
self.entries = {
k: Project.from_dict(v) for k, v in json.load(f).items()
}
except FileNotFoundError:
self.entries = {}

def clear(self):
"""Clears scanned project objects from JSON file and memory"""
if os.path.isfile(self.path):
os.unlink(self.path)
self.entries = {}

def add_entry(self, path: str, entry: Project):
"""Adds an entry to the scanned project object"""
self.entries[path] = entry
if self.auto_save:
self.save()

def save(self):
"""Serialise the state of the scanned project objects to file"""
# don't catch
data = {k: v.to_dict(compact=False) for k, v in self.entries.items()}
with fsspec.open(self.path, "w") as f:
json.dump(data, f)

def filter(self, filters: list[tuple[str, str]]) -> dict[str, Project]:
return {k: v for k, v in self.entries.items() if _match(v, filters)}


# move to Project definition?
def _match(proj: Project, filters: list[tuple[str, str | tuple[str]]]) -> bool:
# TODO: this is all AND, but you can get OR by passing a tuple of values
for cat, value in filters:
# TODO: make categories an enum
if cat == "spec" and value not in proj:
return False
if cat == "artifact" and not proj.all_artifacts(value):
return False
if cat == "content" and not proj.all_contents(value):
return False
return True


library = ProjectLibrary()
library.load()
32 changes: 21 additions & 11 deletions src/projspec/proj/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ class ParseFailed(ValueError):


class Project:
"""Top level representation of a project directory

This holds any parsed project metadata specs, top level contents and artifacts and
any project details from nested child directories.
"""

def __init__(
self,
path: str,
Expand Down Expand Up @@ -209,7 +215,7 @@ def pyproject(self):
pass
return {}

def all_artifacts(self, names=None) -> list:
def all_artifacts(self, names: str | None = None) -> list:
"""A flat list of all the artifact objects nested in this project."""
arts = set(self.artifacts.values())
for spec in self.specs.values():
Expand Down Expand Up @@ -270,14 +276,17 @@ def __contains__(self, item) -> bool:
return item in self.specs or any(item in _ for _ in self.children.values())

def to_dict(self, compact=True) -> dict:
dic = AttrDict(
specs=self.specs,
children=self.children,
url=self.url,
storage_options=self.storage_options,
artifacts=self.artifacts,
contents=self.contents,
)
try:
dic = AttrDict(
specs=self.specs,
children=self.children,
url=self.url,
storage_options=self.storage_options,
artifacts=self.artifacts,
contents=self.contents,
)
except AttributeError:
print(list(self.__dict__.keys()))
if not compact:
dic["klass"] = "project"
return dic.to_dict(compact=compact)
Expand All @@ -296,6 +305,8 @@ def from_dict(dic):
proj = object.__new__(Project)
proj.specs = from_dict(dic["specs"], proj)
proj.children = from_dict(dic["children"], proj)
proj.contents = from_dict(dic["contents"], proj)
proj.artifacts = from_dict(dic["artifacts"], proj)
proj.url = dic["url"]
proj.storage_options = dic["storage_options"]
proj.fs, _ = fsspec.url_to_fs(proj.url, **proj.storage_options)
Expand Down Expand Up @@ -386,8 +397,7 @@ def to_dict(self, compact=True) -> dict:
_contents=self.contents,
_artifacts=self.artifacts,
)
if self.subpath:
dic["subpath"] = self.subpath
dic["subpath"] = self.subpath
if not compact:
dic["klass"] = ["projspec", self.snake_name()]
return dic.to_dict(compact=compact)
Expand Down
4 changes: 3 additions & 1 deletion src/projspec/proj/conda_package.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ def parse(self) -> None:
pass
if meta is None:
raise ParseFailed
art = CondaPackage(proj=self.proj, cmd=["conda-build", self.proj.url])
art = CondaPackage(
proj=self.proj, cmd=["conda-build", f"{self.proj.url}/*.conda"]
)
self._artifacts = AttrDict(conda_package=art)
# TODO: read envs from "outputs" like for Rattler, below?
# or use ``conda render`` to "lock" a templated recipe
Expand Down
4 changes: 2 additions & 2 deletions src/projspec/proj/documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def parse(self) -> None:
# point to a requirements.txt or environment.yaml for conda env.

# Artifact of HTML output. Classically with `make html` (in docs/ unless otherwise
# specified), and the output goes into docs/build/html unless the conf.py file says different.
# RTD actually does
# specified), and the output goes into docs/build/html unless the config.py file
# says different. RTD actually does
# > python -m sphinx -T -W --keep-going -b html -d _build/doctrees -D language=en . $READTHEDOCS_OUTPUT/html
pass
2 changes: 1 addition & 1 deletion src/projspec/proj/pixi.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ def parse(self) -> None:
arts["conda_package"] = CondaPackage(
proj=self.proj,
name=pkg["name"],
fn=f"{pkg['name']}-{pkg['version']}*.conda",
fn=f"{self.proj.url}/{pkg['name']}-{pkg['version']}*.conda",
cmd=["pixi", "build"],
)

Expand Down
3 changes: 1 addition & 2 deletions src/projspec/proj/rust.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@ def parse(self):
with self.proj.fs.open(f"{self.proj.url}/Cargo.toml", "rt") as f:
meta = toml.load(f)
self.contents["desciptive_metadata"] = DescriptiveMetadata(
proj=self.proj, meta=meta["package"], artifacts=set()
proj=self.proj, meta=meta.get("package"), artifacts=set()
)
1


class RustPython(Rust, PythonLibrary):
Expand Down
2 changes: 1 addition & 1 deletion src/projspec/proj/uv.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ def match(self):
try:
with self.proj.fs.open(f"{self.proj.url}/.venv/pyvenv.cfg", "rt") as f:
txt = f.read()
return b"uv =" in txt
return "uv =" in txt
except (OSError, FileNotFoundError):
pass
return False
Expand Down
4 changes: 4 additions & 0 deletions src/projspec/qtapp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Example projspec apps, based on qt

See also ``ipywidget`` or ``panel`` implementations, when they are ready.
"""
Loading