From 6b95cbe03186b5af8a34da362d2f6b999dd9eae2 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 12 Jun 2026 10:26:46 -0400 Subject: [PATCH 1/6] File info --- .../com/projspec/toolwindow/HtmlContent.kt | 36 +++ src/projspec/config.py | 41 +++- src/projspec/proj/base.py | 220 ++++++++++++++++-- src/projspec/textapp/main.py | 36 +++ src/projspec/utils.py | 2 + src/projspec/webui/panel.css | 4 + src/projspec/webui/panel.js | 35 +++ tests/test_webui.py | 83 ++++--- vsextension/src/panel.ts | 36 +++ vsextension/src/projspec.ts | 5 + 10 files changed, 449 insertions(+), 49 deletions(-) diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt index ea6c4d6..37c7362 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt @@ -183,6 +183,7 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var .project .title { font-weight: bold; margin-right: 24px; } .project .url { font-size: 11px; color: var(--vscode-descriptionForeground); word-break: break-all; margin-top: 2px; } .project .storage-opts { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; font-style: italic; } +.project .meta { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; } .project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .chip { @@ -466,6 +467,24 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var return idx >= 0 ? stripped.slice(idx + 1) : stripped; } catch (_) { return url; } } + function fmtSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = parseFloat(bytes); + for (let i = 0; i < units.length; i++) { + if (n < 1024 || i === units.length - 1) + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; + n /= 1024; + } + } + function fmtAge(ts) { + const days = Math.floor((Date.now() / 1000 - parseFloat(ts)) / 86400); + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return days + ' days ago'; + if (days < 365) return Math.floor(days / 30) + ' months ago'; + const yrs = Math.floor(days / 365); + return yrs + ' year' + (yrs > 1 ? 's' : '') + ' ago'; + } function specDisplayName(snake) { return snake; } function iconForSpec(snake) { const entry = info && info.specs && info.specs[snake]; @@ -533,6 +552,23 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var wrap.appendChild(so); } + const metaParts = []; + if (project.file_count != null && project.total_size != null) + metaParts.push(parseInt(project.file_count).toLocaleString() + ' files, ' + fmtSize(project.total_size)); + if (project.is_writable != null) + metaParts.push(project.is_writable === 'True' ? 'writable' : 'read-only'); + if (project.last_modified != null) { + const age = fmtAge(project.last_modified); + const by = project.last_modified_by != null ? project.last_modified_by : null; + metaParts.push('last modified ' + age + (by ? ' by ' + by : '')); + } + if (metaParts.length > 0) { + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = metaParts.join(' · '); + wrap.appendChild(meta); + } + const chips = document.createElement('div'); chips.className = 'chips'; const contents = project.contents || {}; diff --git a/src/projspec/config.py b/src/projspec/config.py index 8dc2df8..7c0d2e5 100644 --- a/src/projspec/config.py +++ b/src/projspec/config.py @@ -39,6 +39,18 @@ def defaults(): "remote_artifact_status": False, "capture_artifact_output": True, "preferred_install_methods": ["conda", "pip"], + "excludes": [ + "bld", + "build", + "dist", + "env", + "envs", + "htmlcov", + "node_modules", + "site", + "target", + "venv", + ], } @@ -56,6 +68,11 @@ def defaults(): "ordered list of preferred installer names for install_tool(), " "e.g. ['uv', 'conda', 'pip']. Empty list uses the platform default." ), + "excludes": ( + "directory names to skip when walking a project tree for child projects " + "and file statistics. Directories whose names start with '.' or '_' are " + "always skipped regardless of this setting." + ), } @@ -124,12 +141,28 @@ def save_conf(conf: dict): @contextmanager def temp_conf(**kwargs): - """Temporarily set the config""" - old = conf.copy() - # TODO: only allow keys that exist in defaults()? + """Temporarily set the config in memory and on disk; both are restored on exit.""" + conf_file = f"{conf_dir()}/projspec.json" + old_mem = conf.copy() + # Snapshot the on-disk file so we can restore it even if set_conf() writes it. + try: + with open(conf_file) as _f: + old_disk: str | None = _f.read() + except FileNotFoundError: + old_disk = None conf.update(kwargs) try: yield finally: conf.clear() - conf.update(old) + conf.update(old_mem) + # Restore (or remove) the config file to its pre-context state. + if old_disk is None: + try: + os.unlink(conf_file) + except FileNotFoundError: + pass + else: + os.makedirs(conf_dir(), exist_ok=True) + with open(conf_file, "w") as _f: + _f.write(old_disk) diff --git a/src/projspec/proj/base.py b/src/projspec/proj/base.py index 403ec4c..5ba2b5e 100644 --- a/src/projspec/proj/base.py +++ b/src/projspec/proj/base.py @@ -1,5 +1,7 @@ import io import logging +import os +import stat from collections.abc import Iterable from itertools import chain from functools import cached_property @@ -22,19 +24,13 @@ logger = logging.getLogger("projspec") registry = {} -# we don't consider these as possible child projects when walk=True -default_excludes = { - # TODO: make this a conf - # TODO: add more here - # we always ignore directories starting with "." or "_" - "bld", - "build", - "dist", - "env", - "envs", # conda-project - "htmlcov", - "node_modules", -} + +def _fmt_size(n: int) -> str: + """Human-readable byte size, e.g. '3.2 MB'.""" + for unit in ("B", "KB", "MB", "GB", "TB"): + if n < 1024 or unit == "TB": + return f"{n:.1f} {unit}" if unit != "B" else f"{n} B" + n /= 1024 class ParseFailed(ValueError): @@ -69,7 +65,7 @@ def __init__( the case that the root did not many any project type. :param types: only allow specs whose names are included. :param xtypes: disallow specs whose names are in this set - :param excludes: directory names to ignore. If None, uses default_excludes. + :param excludes: directory names to ignore. If None, uses `excludes` config value """ if fs is None: fs, path = fsspec.url_to_fs(path, **(storage_options or {})) @@ -87,7 +83,7 @@ def __init__( self.contents = AttrDict() self.artifacts = AttrDict() # read and respect .gitignore? for exclude directories? - self.excludes = excludes or default_excludes + self.excludes = excludes if excludes is not None else set(get_conf("excludes")) self._reset() self.resolve(walk=walk, types=types, xtypes=xtypes) @@ -97,6 +93,7 @@ def _reset(self): self.__dict__.pop("basenames", None) self.__dict__.pop("filelist", None) self.__dict__.pop("pyproject", None) + self.__dict__.pop("_tree_stats", None) self._scanned_files = None # clear cached files self._scanned_files = None @@ -106,6 +103,141 @@ def is_local(self) -> bool: # see also fsspec.utils.can_be_local for more flexibility with caching. return isinstance(self.fs, fsspec.implementations.local.LocalFileSystem) + @cached_property + def _tree_stats(self) -> dict: + """Walk the directory tree and collect aggregate statistics. + + Returns a dict with keys: + file_count int – number of files found (directories excluded) + total_size int – total byte size of all files + is_writable bool | None – True/False/None (None = could not determine) + last_modified float | None – mtime of the most-recently-changed file, as a + Unix timestamp; None if unavailable + last_modified_by str | None – username (or uid string) of the owner of that + file; None if unavailable + """ + file_count = 0 + total_size = 0 + best_mtime: float | None = None + best_info: dict | None = None + + try: + for dirpath, subdirs, files in self.fs.walk( + self.url, topdown=True, detail=True + ): + # Prune excluded directories in-place (topdown=True makes this work) + # subdirs is a dict when detail=True + if isinstance(subdirs, dict): + to_remove = [ + name + for name in list(subdirs) + if name in self.excludes or name.startswith((".", "_")) + ] + for name in to_remove: + del subdirs[name] + file_infos = files.values() if isinstance(files, dict) else [] + else: + # Some backends yield lists even with detail=True; skip pruning + file_infos = [] + + for finfo in file_infos: + if not isinstance(finfo, dict): + continue + size = finfo.get("size") or 0 + file_count += 1 + total_size += size + mtime = finfo.get("mtime") or finfo.get("LastModified") + if mtime is not None: + # mtime may be a datetime; normalise to float + ts = ( + mtime.timestamp() + if hasattr(mtime, "timestamp") + else float(mtime) + ) + if best_mtime is None or ts > best_mtime: + best_mtime = ts + best_info = finfo + except Exception: + logger.debug("_tree_stats walk failed for %s", self.url, exc_info=True) + + # ── is_writable ────────────────────────────────────────────────────── + is_writable: bool | None = None + if self.is_local(): + try: + is_writable = os.access(self.url, os.W_OK) + except Exception: + pass + else: + # For remote backends: inspect mode bits if present, otherwise None. + try: + root_info = self.fs.info(self.url) + mode = root_info.get("mode") + if mode is not None: + is_writable = bool(mode & stat.S_IWUSR) + # Some backends expose an explicit writable flag (e.g. SMB) + elif "writable" in root_info: + is_writable = bool(root_info["writable"]) + except Exception: + pass + + # ── last_modified_by ───────────────────────────────────────────────── + last_modified_by: str | None = None + if best_info is not None: + uid = best_info.get("uid") + owner = best_info.get("owner") or best_info.get("Owner") + if uid is not None and self.is_local(): + try: + import pwd + + last_modified_by = pwd.getpwuid(int(uid)).pw_name + except Exception: + last_modified_by = str(uid) + elif owner is not None: + last_modified_by = str(owner) + + return { + "file_count": file_count, + "total_size": total_size, + "is_writable": is_writable, + "last_modified": best_mtime, + "last_modified_by": last_modified_by, + } + + @property + def file_count(self) -> int: + """Total number of files in the directory tree (excluding ignored directories).""" + return self._tree_stats["file_count"] + + @property + def total_size(self) -> int: + """Total byte size of all files in the directory tree.""" + return self._tree_stats["total_size"] + + @property + def is_writable(self) -> bool | None: + """Whether the project root appears to be writable. + + Returns True/False for local filesystems and backends that expose mode + bits or an explicit writable flag. Returns None when the information is + not available (e.g. read-only remote backends without metadata). + """ + return self._tree_stats["is_writable"] + + @property + def last_modified(self) -> float | None: + """Unix timestamp of the most recently modified file in the tree, or None.""" + return self._tree_stats["last_modified"] + + @property + def last_modified_by(self) -> str | None: + """Username (or uid string) of the owner of the most recently modified file. + + Resolved via the ``pwd`` module on local filesystems. For remote backends + that expose an ``owner`` field in their file-info dict the raw value is + returned as a string. None when not available. + """ + return self._tree_stats["last_modified_by"] + @property def scanned_files(self): if self._scanned_files is None: @@ -228,6 +360,8 @@ def text_summary(self, bare=False) -> str: if bare else f"\n" ) + if not bare: + txt += self._stats_line() + "\n" bits = [ f" {'/'}: {' '.join(type(_).__name__ for _ in chain(self.specs.values(), self.contents.values(), self.artifacts.values()))}" ] + [ @@ -236,12 +370,52 @@ def text_summary(self, bare=False) -> str: ] return txt + "\n".join(bits) + def _stats_line(self) -> str: + """One-line summary of filesystem statistics for this project.""" + parts = [] + + # file count + size + count = self.file_count + size = self.total_size + if count or size: + parts.append(f"{count:,} files, {_fmt_size(size)}") + + # writable + if self.is_writable is not None: + parts.append("writable" if self.is_writable else "read-only") + + # last modified + lm = self.last_modified + if lm is not None: + import datetime + + age = datetime.datetime.now() - datetime.datetime.fromtimestamp(lm) + days = age.days + if days == 0: + age_str = "today" + elif days == 1: + age_str = "yesterday" + elif days < 30: + age_str = f"{days} days ago" + elif days < 365: + age_str = f"{days // 30} months ago" + else: + age_str = f"{days // 365} year{'s' if days >= 730 else ''} ago" + by = self.last_modified_by + if by: + parts.append(f"last modified {age_str} by {by}") + else: + parts.append(f"last modified {age_str}") + + return " " + " · ".join(parts) if parts else "" + def __repr__(self): return f"" def __str__(self): - txt = "\n\n{}".format( + txt = "\n{}\n\n{}".format( self.fs.unstrip_protocol(self.url), + self._stats_line(), "\n\n".join(str(_) for _ in self.specs.values()), ) if self.contents or self.artifacts: @@ -355,6 +529,11 @@ def to_dict(self, compact=True) -> dict: storage_options=self.storage_options, artifacts=self.artifacts, contents=self.contents, + file_count=self.file_count, + total_size=self.total_size, + is_writable=self.is_writable, + last_modified=self.last_modified, + last_modified_by=self.last_modified_by, ) if not compact: dic["klass"] = "project" @@ -381,6 +560,15 @@ def from_dict(dic): proj.path = dic["url"] proj.storage_options = dic["storage_options"] proj.fs, proj.url = fsspec.url_to_fs(proj.path, **proj.storage_options) + # Restore cached tree stats so a round-tripped Project never re-walks. + # Keys default to None if absent (e.g. older serialised data). + proj.__dict__["_tree_stats"] = { + "file_count": dic.get("file_count", 0), + "total_size": dic.get("total_size", 0), + "is_writable": dic.get("is_writable"), + "last_modified": dic.get("last_modified"), + "last_modified_by": dic.get("last_modified_by"), + } return proj def create(self, name: str) -> list[str]: diff --git a/src/projspec/textapp/main.py b/src/projspec/textapp/main.py index 2bfcfc7..dbcde37 100644 --- a/src/projspec/textapp/main.py +++ b/src/projspec/textapp/main.py @@ -170,6 +170,22 @@ def _basename(url: str) -> str: return (url.rstrip("/").rsplit("/", 1)[-1]) or url +def _fmt_age(ts: float) -> str: + import datetime + + days = (datetime.datetime.now() - datetime.datetime.fromtimestamp(ts)).days + if days == 0: + return "today" + if days == 1: + return "yesterday" + if days < 30: + return f"{days} days ago" + if days < 365: + return f"{days // 30} months ago" + yrs = days // 365 + return f"{yrs} year{'s' if yrs > 1 else ''} ago" + + def _is_enum(v: Any) -> bool: """True for a serialised enum: ``{klass:['enum', name], value: ...}``.""" return ( @@ -635,6 +651,7 @@ class ProjectWidget(Static): ProjectWidget .title { color: #dcb67a; text-style: bold; } ProjectWidget .url { color: #858585; } ProjectWidget .storage { color: #858585; text-style: italic; } + ProjectWidget .meta { color: #858585; } /* Each chips row is a plain horizontal run of ``Chip`` statics laid out left-to-right. ``#chips-wrap`` stacks multiple such rows when the total chip width exceeds ``_CHIPS_ROW_WIDTH``. */ @@ -659,6 +676,25 @@ def compose(self) -> ComposeResult: so = self.project.get("storage_options") or {} if so: yield Static(f"storage_options: {json.dumps(so)}", classes="storage") + meta_parts = [] + file_count = self.project.get("file_count") + total_size = self.project.get("total_size") + if file_count is not None and total_size is not None: + from projspec.proj.base import _fmt_size + + meta_parts.append( + f"{int(file_count):,} files, {_fmt_size(int(total_size))}" + ) + is_writable = self.project.get("is_writable") + if is_writable is not None: + meta_parts.append("writable" if is_writable else "read-only") + last_modified = self.project.get("last_modified") + if last_modified is not None: + age = _fmt_age(float(last_modified)) + by = self.project.get("last_modified_by") + meta_parts.append("last modified " + age + (f" by {by}" if by else "")) + if meta_parts: + yield Static(" · ".join(meta_parts), classes="meta") # Build the full list of chips first, then split into horizontal # rows that each fit into roughly ``_CHIPS_ROW_WIDTH`` cells. This # keeps chips visible in narrow library panes (a plain Horizontal diff --git a/src/projspec/utils.py b/src/projspec/utils.py index 67c3bd2..1551303 100644 --- a/src/projspec/utils.py +++ b/src/projspec/utils.py @@ -84,6 +84,8 @@ def __dir__(self): def to_dict(obj, compact=True): """Make entity into JSON-serialisable dict representation""" + if obj is None: + return None if isinstance(obj, dict): return { k: ( diff --git a/src/projspec/webui/panel.css b/src/projspec/webui/panel.css index 3c65aee..3c19d52 100644 --- a/src/projspec/webui/panel.css +++ b/src/projspec/webui/panel.css @@ -97,6 +97,10 @@ body { font-size: 11px; color: var(--vscode-descriptionForeground, #858585); margin-top: 2px; font-style: italic; } +.project .meta { + font-size: 11px; color: var(--vscode-descriptionForeground, #858585); + margin-top: 2px; +} .project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .chip { diff --git a/src/projspec/webui/panel.js b/src/projspec/webui/panel.js index fe30acc..f80f8d8 100644 --- a/src/projspec/webui/panel.js +++ b/src/projspec/webui/panel.js @@ -92,6 +92,24 @@ return idx >= 0 ? stripped.slice(idx + 1) : stripped; } catch { return url; } } + function fmtSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = parseFloat(bytes); + for (let i = 0; i < units.length; i++) { + if (n < 1024 || i === units.length - 1) + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; + n /= 1024; + } + } + function fmtAge(ts) { + const days = Math.floor((Date.now() / 1000 - parseFloat(ts)) / 86400); + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return days + ' days ago'; + if (days < 365) return Math.floor(days / 30) + ' months ago'; + const yrs = Math.floor(days / 365); + return yrs + ' year' + (yrs > 1 ? 's' : '') + ' ago'; + } function iconForSpec(snake) { const entry = info && info.specs && info.specs[snake]; return (entry && entry.icon) || DEFAULT_ICONS.spec; @@ -172,6 +190,23 @@ wrap.appendChild(so); } + const metaParts = []; + if (project.file_count != null && project.total_size != null) + metaParts.push(parseInt(project.file_count).toLocaleString() + ' files, ' + fmtSize(project.total_size)); + if (project.is_writable != null) + metaParts.push(project.is_writable === 'True' ? 'writable' : 'read-only'); + if (project.last_modified != null) { + const age = fmtAge(project.last_modified); + const by = project.last_modified_by != null ? project.last_modified_by : null; + metaParts.push('last modified ' + age + (by ? ' by ' + by : '')); + } + if (metaParts.length > 0) { + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = metaParts.join(' · '); + wrap.appendChild(meta); + } + const chips = document.createElement('div'); chips.className = 'chips'; const contents = project.contents || {}; diff --git a/tests/test_webui.py b/tests/test_webui.py index fa63f43..4e4f528 100644 --- a/tests/test_webui.py +++ b/tests/test_webui.py @@ -142,8 +142,11 @@ def test_ipywidget_handlers_respond(tmp_path): lib_file = tmp_path / "lib.json" lib = ProjectLibrary(str(lib_file), auto_save=False) # Stock the library with one real entry so handlers have something to - # operate on. Use a key with the file:// scheme to mimic add_to_library. - lib.entries["file:///data"] = Project("/data", walk=False) + # operate on. Use the tmp_path itself so the test is not tied to any + # container-specific path. + proj_path = str(tmp_path) + proj_url = "file://" + proj_path + lib.entries[proj_url] = Project(proj_path, walk=False) widget = lib.ipywidget() outbox: list[dict] = [] @@ -161,7 +164,7 @@ def fire(cmd: str, **kwargs) -> tuple[list[dict], list[str]]: msgs, _ = fire("ready") assert any(m.get("type") == "data" for m in msgs) data = next(m for m in msgs if m.get("type") == "data") - assert list(data["library"]) == ["file:///data"] + assert list(data["library"]) == [proj_url] # reload: must NOT wipe the library when the backing file is absent. # This guards the bug reported in the hand-off: ``library.load()`` sets @@ -169,12 +172,12 @@ def fire(cmd: str, **kwargs) -> tuple[list[dict], list[str]]: # every Reload click destroys the widget's state. assert not lib_file.exists() msgs, _ = fire("reload") - assert list(lib.entries) == ["file:///data"], list(lib.entries) + assert list(lib.entries) == [proj_url], list(lib.entries) # rescan: must preserve the library key (the frontend selection is # keyed on that URL). - fire("rescan", url="file:///data") - assert list(lib.entries) == ["file:///data"] + fire("rescan", url=proj_url) + assert list(lib.entries) == [proj_url] # add: opens the text-entry modal. msgs, _ = fire("add") @@ -185,11 +188,11 @@ def fire(cmd: str, **kwargs) -> tuple[list[dict], list[str]]: assert tlist and "Not a directory" in tlist[0] # createSpec: opens the create-spec modal. - msgs, _ = fire("createSpec", url="file:///data") + msgs, _ = fire("createSpec", url=proj_url) assert any(m.get("type") == "openCreateSpecModal" for m in msgs) # openWith with an unknown tool: handled with a toast, no spawn. - _, tlist = fire("openWith", tool="does-not-exist", url="file:///data") + _, tlist = fire("openWith", tool="does-not-exist", url=proj_url) assert tlist and "Unknown openWith tool" in tlist[0] # revealFile on a non-existent path: toast, no exception. @@ -203,8 +206,8 @@ def fire(cmd: str, **kwargs) -> tuple[list[dict], list[str]]: # removeFromLibrary must not call save() when auto_save is False. # (If it did, the assertion at the top of this test would have failed # for later handlers - but check explicitly.) - fire("removeFromLibrary", url="file:///data") - assert "file:///data" not in lib.entries + fire("removeFromLibrary", url=proj_url) + assert proj_url not in lib.entries assert not lib_file.exists(), "removeFromLibrary must respect auto_save" @@ -229,35 +232,57 @@ def test_make_cwd_uses_project_path_not_library_key(tmp_path, monkeypatch): ``_add_confirmed`` they used to be the child's basename (e.g. ``qtapp``). Feeding that back into ``Project(key)`` or using it as a filesystem path made subprocesses run in ``/qtapp`` - the notebook's - launch directory - instead of ``/data/qtapp``. This test pins the - fixed behaviour. + launch directory - instead of the project's own location. This test + pins the fixed behaviour using a synthetic library entry so it is not + tied to any specific on-disk layout. """ import subprocess + import fsspec pytest.importorskip("anywidget") pytest.importorskip("ipywidgets") - from projspec import Project - from projspec.utils import is_installed + from projspec.artifact.process import Process + from projspec.proj.base import Project, ProjectSpec + from projspec.utils import AttrDict, is_installed is_installed.cache[(is_installed.env, "code")] = True - # Build a library where a child is keyed on its basename, mimicking the - # pre-fix legacy shape. The stored Project's .path is the *real* - # absolute location (/data/vsextension). + # Build a synthetic Project rooted at tmp_path with a Process artifact + # named "launch" under a spec named "fake_spec". The library is keyed + # on a bare basename ("myproject") to replicate the pre-fix shape where + # the key was not a full path and could not be resolved as a directory. + proj_path = str(tmp_path) + proj = object.__new__(Project) + proj.path = proj_path + proj.storage_options = {} + proj.fs, proj.url = fsspec.url_to_fs(proj_path) + proj.children = AttrDict() + proj.contents = AttrDict() + proj.artifacts = AttrDict() + proj.__dict__["_tree_stats"] = { + "file_count": 0, + "total_size": 0, + "is_writable": None, + "last_modified": None, + "last_modified_by": None, + } + + proc = Process(proj, cmd=["echo", "hi"]) + spec = object.__new__(ProjectSpec) + spec.proj = proj + spec._contents = AttrDict() + spec._artifacts = AttrDict(launch=proc) + proj.specs = AttrDict(fake_spec=spec) + lib = ProjectLibrary(str(tmp_path / "lib.json"), auto_save=False) - root = Project("/data", walk=True) - for cname, cproj in (root.children or {}).items(): - if cname == "vsextension" and cproj.specs.get("v_s_code"): - lib.entries[cname] = cproj - break - else: - pytest.skip("vsextension child not present in /data scan") + # Key is deliberately a bare name, not a full path. + lib.entries["myproject"] = proj # Kernel cwd is deliberately somewhere else. monkeypatch.chdir("/") w = lib.ipywidget() - w._toast = lambda m: None # swallow - w.send = lambda c, buffers=None: None # swallow + w._toast = lambda m: None + w.send = lambda c, buffers=None: None captured = {} @@ -280,8 +305,8 @@ def __exit__(self, *_args): w, { "cmd": "make", - "url": "vsextension", - "spec": "v_s_code", + "url": "myproject", + "spec": "fake_spec", "artifactType": "launch", }, None, @@ -289,4 +314,4 @@ def __exit__(self, *_args): except SystemExit: pass - assert captured.get("cwd") == "/data/vsextension", captured + assert captured.get("cwd") == proj_path, captured diff --git a/vsextension/src/panel.ts b/vsextension/src/panel.ts index 87cd62f..44c9ccc 100644 --- a/vsextension/src/panel.ts +++ b/vsextension/src/panel.ts @@ -568,6 +568,7 @@ body { margin: 0; padding: 0; font-family: var(--vscode-font-family); color: var .project .title { font-weight: bold; margin-right: 24px; } .project .url { font-size: 11px; color: var(--vscode-descriptionForeground); word-break: break-all; margin-top: 2px; } .project .storage-opts { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; font-style: italic; } +.project .meta { font-size: 11px; color: var(--vscode-descriptionForeground); margin-top: 2px; } .project .chips { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 6px; } .chip { @@ -828,6 +829,24 @@ const PANEL_JS = String.raw` return idx >= 0 ? stripped.slice(idx + 1) : stripped; } catch { return url; } } + function fmtSize(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let n = parseFloat(bytes); + for (let i = 0; i < units.length; i++) { + if (n < 1024 || i === units.length - 1) + return (i === 0 ? n.toFixed(0) : n.toFixed(1)) + ' ' + units[i]; + n /= 1024; + } + } + function fmtAge(ts) { + const days = Math.floor((Date.now() / 1000 - parseFloat(ts)) / 86400); + if (days === 0) return 'today'; + if (days === 1) return 'yesterday'; + if (days < 30) return days + ' days ago'; + if (days < 365) return Math.floor(days / 30) + ' months ago'; + const yrs = Math.floor(days / 365); + return yrs + ' year' + (yrs > 1 ? 's' : '') + ' ago'; + } function specDisplayName(snake) { return snake; } function iconForSpec(snake) { const entry = info && info.specs && info.specs[snake]; @@ -895,6 +914,23 @@ const PANEL_JS = String.raw` wrap.appendChild(so); } + const metaParts = []; + if (project.file_count != null && project.total_size != null) + metaParts.push(parseInt(project.file_count).toLocaleString() + ' files, ' + fmtSize(project.total_size)); + if (project.is_writable != null) + metaParts.push(project.is_writable === 'True' ? 'writable' : 'read-only'); + if (project.last_modified != null) { + const age = fmtAge(project.last_modified); + const by = project.last_modified_by != null ? project.last_modified_by : null; + metaParts.push('last modified ' + age + (by ? ' by ' + by : '')); + } + if (metaParts.length > 0) { + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = metaParts.join(' · '); + wrap.appendChild(meta); + } + const chips = document.createElement('div'); chips.className = 'chips'; const contents = project.contents || {}; diff --git a/vsextension/src/projspec.ts b/vsextension/src/projspec.ts index 8393032..37c9aa7 100644 --- a/vsextension/src/projspec.ts +++ b/vsextension/src/projspec.ts @@ -163,6 +163,11 @@ export interface ProjectData { artifacts: Record; children?: Record; klass?: [string, string]; + file_count?: string; + total_size?: string; + is_writable?: string; + last_modified?: string; + last_modified_by?: string; } export interface SpecData { From a20acace3451fb38071e27a56d1cec7e8fc72e34 Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 12 Jun 2026 11:16:22 -0400 Subject: [PATCH 2/6] Allow glob/multifile scan --- src/projspec/__init__.py | 4 +- src/projspec/__main__.py | 57 ++++++++++++--------- src/projspec/proj/base.py | 12 +++++ src/projspec/proj/pixi.py | 2 +- src/projspec/utils.py | 103 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 151 insertions(+), 27 deletions(-) diff --git a/src/projspec/__init__.py b/src/projspec/__init__.py index bed4dba..f3f94e2 100644 --- a/src/projspec/__init__.py +++ b/src/projspec/__init__.py @@ -3,6 +3,6 @@ import projspec.content import projspec.artifact import projspec.config -from projspec.utils import get_cls +from projspec.utils import get_cls, scan_glob -__all__ = ["Project", "ProjectSpec", "get_cls"] +__all__ = ["Project", "ProjectSpec", "get_cls", "scan_glob"] diff --git a/src/projspec/__main__.py b/src/projspec/__main__.py index 53505cc..ada5cb6 100755 --- a/src/projspec/__main__.py +++ b/src/projspec/__main__.py @@ -73,7 +73,7 @@ def version(): @main.command("scan") -@click.argument("path", default=".") +@click.argument("patterns", nargs=-1) @click.option( "--storage_options", default="", @@ -101,11 +101,13 @@ def version(): default=False, help="HTML output, for projects only", ) -@click.option("--walk", is_flag=True, help="To descend into all child directories") +@click.option( + "--walk", is_flag=True, help="Descend into child directories of each match" +) @click.option("--summary", is_flag=True, help="Show abbreviated output") -@click.option("--library", is_flag=True, help="Add to library") +@click.option("--library", is_flag=True, help="Add each result to the library") def scan( - path, + patterns, storage_options, types, xtypes, @@ -115,32 +117,39 @@ def scan( summary, library, ): - """Scan the given path for projects, and display + """Scan directories and display results. - path: str, path to the project directory, defaults to "." + PATTERNS is one or more directory paths or glob expressions. When the + shell expands a glob before invoking projspec, each expanded path is + passed as a separate argument. Glob expressions containing wildcards + (e.g. '~/projects/*') are expanded by projspec itself via fsspec. + If no PATTERNS are given, the current directory is scanned. """ + from projspec.utils import scan_glob + if types in {"ALL", ""}: types = None else: types = types.split(",") - proj = projspec.Project( - path, - storage_options=storage_options, - types=types, - xtypes=xtypes, - walk=walk, - ) - if summary: - print(proj.text_summary()) - else: - if json_out: - print(json.dumps(proj.to_dict(compact=False))) - elif html_out: - print(proj._repr_html_()) - else: - print(proj) - if library: - proj.add_to_library() + + for pattern in patterns or (".",): + for proj in scan_glob( + pattern, + types=types, + xtypes=xtypes, + walk=walk, + storage_options=storage_options, + add_to_library=library, + ): + if summary: + print(proj.text_summary()) + else: + if json_out: + print(json.dumps(proj.to_dict(compact=False))) + elif html_out: + print(proj._repr_html_()) + else: + print(proj) @main.command("info") diff --git a/src/projspec/proj/base.py b/src/projspec/proj/base.py index 5ba2b5e..42d2471 100644 --- a/src/projspec/proj/base.py +++ b/src/projspec/proj/base.py @@ -1,4 +1,5 @@ import io +import json import logging import os import stat @@ -621,10 +622,21 @@ def add_to_library(self, path=None): """Add this project to the current session library""" # TODO: prevent overwrite? from projspec.library import ProjectLibrary + import json + # precheck serialisability (prevents malformed JSON diring save) + json.dumps(self.to_dict(compact=False)) library = ProjectLibrary(path) library.add_entry(self.fs.unstrip_protocol(self.url), self) + def __bool__(self): + return ( + bool(self.specs) + or bool(self.children) + or bool(self.contents) + or bool(self.artifacts) + ) + class ProjectSpec: """A project specification diff --git a/src/projspec/proj/pixi.py b/src/projspec/proj/pixi.py index 8a37e49..e03865e 100644 --- a/src/projspec/proj/pixi.py +++ b/src/projspec/proj/pixi.py @@ -98,7 +98,7 @@ def parse(self) -> None: details if isinstance(details, list) else details["features"] ) for feat_name in feats: - feat.update(meta["feature"][feat_name]) + feat.update(meta["feature"].get(feat_name, {})) if isinstance(details, list) or not details.get("no-default-feature"): feat.update(meta) extract_feature(feat, procs, commands, self, env=env_name) diff --git a/src/projspec/utils.py b/src/projspec/utils.py index 1551303..fa70261 100644 --- a/src/projspec/utils.py +++ b/src/projspec/utils.py @@ -453,3 +453,106 @@ def _ipynb_to_py(data: str) -> str: return "\n\n###\n\n".join( ["".join(_["source"]) for _ in everything["cells"] if _["cell_type"] == "code"] ) + + +def scan_glob( + pattern: str, + *, + types=None, + xtypes=None, + walk: bool = False, + storage_options: str | dict = "", + add_to_library: bool = False, +): + """Scan every directory matching *pattern* and yield a ``Project`` for each. + + Parameters + ---------- + pattern: + A glob pattern passed directly to :func:`fsspec.AbstractFileSystem.glob`. + Works for any fsspec-supported filesystem (local, S3, GCS, …). + ``~`` is expanded for local paths via :func:`os.path.expanduser`. + Non-wildcard paths are accepted too and behave identically to a + single-directory scan. + types: + Spec type names to include (list of str, or ``None`` for all). + xtypes: + Spec type names to exclude (list of str, or ``None`` for none). + walk: + If ``True``, each matched directory is also walked for child + projects (passed through to :class:`projspec.Project`). + storage_options: + Storage options for remote filesystems. May be a JSON string or + a plain ``dict``; an empty string means no options. + add_to_library: + If ``True``, each successfully scanned project is added to the + default project library via :meth:`projspec.Project.add_to_library`. + + Yields + ------ + projspec.Project + One project per matched directory, in the order returned by the + glob expansion. Directories that fail to scan are skipped with a + logged warning. + + Examples + -------- + >>> for proj in scan_glob("~/projects/*"): + ... print(proj.text_summary(bare=True)) + + >>> projects = list(scan_glob("s3://my-bucket/workspaces/*", + ... storage_options={"anon": False})) + """ + import fsspec + + from projspec.proj import Project + + # Normalise storage_options to a dict + if isinstance(storage_options, str): + import json + + so: dict = json.loads(storage_options) if storage_options.strip() else {} + else: + so = dict(storage_options) + + # Obtain the appropriate filesystem for the given pattern. + # For local paths, expand ~ before handing off to fsspec. + fs, path = fsspec.url_to_fs(pattern, **so) + if isinstance(fs, fsspec.implementations.local.LocalFileSystem): + path = os.path.expanduser(path) + + candidates = sorted(fs.glob(path)) + + # If the pattern contained no wildcards and matched nothing, still try + # the literal path so the caller gets a meaningful error rather than silence. + if not candidates: + candidates = [path] + + for candidate in candidates: + try: + if fs.info(candidate)["type"] != "directory": + continue + except FileNotFoundError: + logger.warning("Path not found: %s", candidate) + continue + try: + proj = Project( + candidate, + fs=fs, + types=types, + xtypes=xtypes, + walk=walk, + ) + except Exception: + logger.warning("Failed to scan %s", candidate, exc_info=True) + continue + if not proj: + continue + if add_to_library: + try: + proj.add_to_library() + except TypeError: + import warnings + + warnings.warn(f"{repr(proj)} failed to serialise") + yield proj From d4fb8da49ff05c6e9c083ca64f2822bdf88edefb Mon Sep 17 00:00:00 2001 From: Martin Durant Date: Fri, 12 Jun 2026 11:49:07 -0400 Subject: [PATCH 3/6] Update all UIs --- .../com/projspec/toolwindow/HtmlContent.kt | 137 +++++++++++++++- src/projspec/qtapp/main.py | 1 + src/projspec/textapp/main.py | 52 ++++-- src/projspec/webui/ipywidget.py | 41 ++--- src/projspec/webui/panel.css | 58 +++++++ src/projspec/webui/panel.html | 24 ++- src/projspec/webui/panel.js | 58 ++++++- tests/test_webui.py | 2 +- vsextension/src/panel.ts | 152 +++++++++++++++++- vsextension/src/projspec.ts | 5 +- 10 files changed, 487 insertions(+), 43 deletions(-) diff --git a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt index 37c7362..2e81e49 100644 --- a/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt +++ b/pycharm_plugin/src/main/kotlin/com/projspec/toolwindow/HtmlContent.kt @@ -39,7 +39,13 @@ object HtmlContent {
- +
+ + +
@@ -64,6 +70,22 @@ object HtmlContent {
+