Skip to content
Draft
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
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ dependencies = [
test = ["pytest", "pytest-cov", "django", "streamlit", "copier", "jinja2-time", "flask",
"maturin", "uv", "briefcase"]
qt = ["pyqt>5,<6", "pyqtwebengin>5,<6"]
textual = ["textual>=0.80"]

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

[tool.poetry.extras]
po_test = ["pytest"]
Expand Down
2 changes: 2 additions & 0 deletions src/projspec/content/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ def __repr__(self) -> str:

def _repr_html_(self) -> str:
"""Jupyter rich display — returns cached HTML, rendering on first call."""
# TODO: this is probably not what we want jupyter to dysplay, but it's
# convenient for now.
if self._html is None:
from projspec.content.data_html import repr_html

Expand Down
46 changes: 5 additions & 41 deletions tests/test_data_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,21 +8,11 @@
from projspec.utils import from_dict


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _data_project(tmp_path):
"""Return a projspec.Project rooted at *tmp_path* (no walk needed)."""
return projspec.Project(str(tmp_path))


# ---------------------------------------------------------------------------
# Detection tests
# ---------------------------------------------------------------------------


class TestDataDetection:
def test_csv_detected(self, tmp_path):
(tmp_path / "data.csv").write_text("x,y\n1,2\n3,4\n")
Expand All @@ -45,11 +35,6 @@ def test_no_data_files_not_detected(self, tmp_path):
assert "data" not in proj.specs


# ---------------------------------------------------------------------------
# Parse / DataResource field tests
# ---------------------------------------------------------------------------


class TestDataParse:
def test_single_csv_resource(self, tmp_path):
(tmp_path / "sales.csv").write_text("col1,col2\n1,a\n2,b\n")
Expand Down Expand Up @@ -98,11 +83,6 @@ def test_total_size_nonzero(self, tmp_path):
assert dr.total_size > 0


# ---------------------------------------------------------------------------
# Serialisation: to_dict
# ---------------------------------------------------------------------------


class TestDataResourceToDict:
def _make_dr(self, tmp_path):
(tmp_path / "items.csv").write_text("id,val\n1,a\n2,b\n")
Expand All @@ -121,11 +101,6 @@ def test_compact_omits_html(self, tmp_path):
assert "_html" not in d


# ---------------------------------------------------------------------------
# Serialisation: from_dict round-trip
# ---------------------------------------------------------------------------


class TestDataResourceRoundTrip:
def _roundtrip(self, dr):
"""Serialise to JSON and rehydrate, returning the new DataResource."""
Expand Down Expand Up @@ -211,23 +186,14 @@ def test_roundtrip_html_survives_missing_sample_path(self, tmp_path):
assert dr2._repr_html_() == html_original


# ---------------------------------------------------------------------------
# Conditional parse: sentinel / byte-majority logic
# ---------------------------------------------------------------------------


class TestDataConditionalParse:
"""Tests for the 'other project types present' guard in Data.parse()."""

# -- helpers --

def _big_csv(self, path, rows=500):
"""Write a CSV large enough to dominate byte counts."""
content = "id,value\n" + "\n".join(f"{i},{i * 2}" for i in range(rows))
path.write_text(content)

# -- pure data directories (no sentinels) --

def test_pure_data_dir_no_sentinel(self, tmp_path):
"""No sentinel → Data always parsed regardless of byte ratios."""
(tmp_path / "data.csv").write_text("x\n1\n")
Expand All @@ -248,10 +214,8 @@ def test_dvc_companion_not_a_sentinel(self, tmp_path):
proj = _data_project(tmp_path)
assert "data" in proj.specs

# -- mixed dirs where data dominates --

def test_sentinel_present_data_majority(self, tmp_path):
"""Sentinel present but data files are majority of bytes → Data parsed."""
"""Sentinel is present, but data files are the majority of bytes → Data parsed."""
self._big_csv(tmp_path / "data.csv") # large data file
(tmp_path / "pyproject.toml").write_text(
"[project]\nname='x'\n"
Expand Down Expand Up @@ -299,7 +263,7 @@ def test_has_non_data_sentinels_true(self, tmp_path):

(tmp_path / "data.csv").write_text("x\n1\n")
(tmp_path / "pyproject.toml").write_text("")
proj = projspec.Project.__new__(projspec.Project)
proj = object.__new__(projspec.Project)
import fsspec

proj.fs = fsspec.filesystem("file")
Expand All @@ -317,7 +281,7 @@ def test_has_non_data_sentinels_false(self, tmp_path):
from projspec.proj.data_dir import Data

(tmp_path / "data.csv").write_text("x\n1\n")
proj = projspec.Project.__new__(projspec.Project)
proj = object.__new__(projspec.Project)
import fsspec

proj.fs = fsspec.filesystem("file")
Expand All @@ -336,7 +300,7 @@ def test_data_bytes_majority_true(self, tmp_path):

self._big_csv(tmp_path / "data.csv")
(tmp_path / "small.py").write_text("x = 1\n")
proj = projspec.Project.__new__(projspec.Project)
proj = object.__new__(projspec.Project)
import fsspec

proj.fs = fsspec.filesystem("file")
Expand All @@ -351,7 +315,7 @@ def test_data_bytes_majority_false(self, tmp_path):

(tmp_path / "main.py").write_text("x = 1\n" * 5000)
(tmp_path / "tiny.csv").write_text("a\n1\n")
proj = projspec.Project.__new__(projspec.Project)
proj = object.__new__(projspec.Project)
import fsspec

proj.fs = fsspec.filesystem("file")
Expand Down
50 changes: 50 additions & 0 deletions textapp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
Textual TUI for projspec
-------------------------

Terminal-based filesystem and library browser, functionally equivalent to
``qtapp`` but running entirely in the terminal via [Textual](https://textual.textualize.io/).

## Layout

```
┌────────────────┬────────────────┬──────────────────────────┐
│ Filesystem │ Library │ Details │
│ (left) │ (centre) │ (right) │
│ │ │ │
│ 📁 myproject │ myproject │ myproject │
│ 📁 other │ python_lib │ python_library │
│ 📄 README.md │ git_repo │ packages: [...] │
│ │ • wheel Make │ git_repo │
│ │ │ branch: main │
└────────────────┴────────────────┴──────────────────────────┘
```

- **Left** — filesystem tree. Selecting a directory parses it with projspec
and adds it to the library if any specs are matched.
- **Centre** — library panel showing all known projects with their specs,
contents, and artifacts. Selecting an artifact node triggers `make`.
- **Right** — full detail tree for the selected project.

## Key bindings

| Key | Action |
|-----|--------|
| `h` | Go to home directory |
| `u` | Go up one directory level |
| `g` | Go to an arbitrary path (opens dialog) |
| `s` | Scan the current directory (walk=True, adds all sub-projects) |
| `c` | Create a new project type in the current directory (opens dialog) |
| `q` / `Ctrl+C` | Quit |

## Running

```bash
python textapp/main.py [path]
```

Or, after installing the package with the `textual` extra:

```bash
pip install "projspec[textual]"
projspec-tui [path]
```
4 changes: 4 additions & 0 deletions textapp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Example projspec library app, based on Textual (terminal UI).

See also the ``qtapp`` implementation for a Qt-based desktop GUI.
"""
Loading
Loading