dtu-env 0.1.0__tar.gz

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.
@@ -0,0 +1,36 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v*"
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
13
+ - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
14
+ with:
15
+ python-version: "3.12"
16
+ - name: Install build dependencies
17
+ run: pip install build
18
+ - name: Build sdist and wheel
19
+ run: python -m build
20
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
21
+ with:
22
+ name: dist
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment: pypi
29
+ permissions:
30
+ id-token: write
31
+ steps:
32
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
33
+ with:
34
+ name: dist
35
+ path: dist/
36
+ - uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
@@ -0,0 +1,5 @@
1
+ dist/
2
+ *.egg-info/
3
+ __pycache__/
4
+ *.pyc
5
+ build/
dtu_env-0.1.0/LICENSE ADDED
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, DTU Python Support
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ 1. Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ 2. Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ 3. Neither the name of the copyright holder nor the names of its contributors
17
+ may be used to endorse or promote products derived from this software
18
+ without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
21
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
22
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
dtu_env-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,63 @@
1
+ Metadata-Version: 2.4
2
+ Name: dtu-env
3
+ Version: 0.1.0
4
+ Summary: DTU course environment manager — install and manage conda environments for DTU courses
5
+ Project-URL: Homepage, https://pythonsupport.dtu.dk
6
+ Project-URL: Repository, https://github.com/philipnickel/dtu-env
7
+ Project-URL: Bug Tracker, https://github.com/philipnickel/dtu-env/issues
8
+ Author-email: DTU Python Support <pythonsupport@dtu.dk>
9
+ License-Expression: BSD-3-Clause
10
+ License-File: LICENSE
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Education
14
+ Classifier: License :: OSI Approved :: BSD License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Education
22
+ Classifier: Topic :: System :: Installation/Setup
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: pyyaml>=6.0
25
+ Requires-Dist: requests>=2.28.0
26
+ Requires-Dist: rich>=13.0
27
+ Requires-Dist: textual>=0.80.0
28
+ Description-Content-Type: text/markdown
29
+
30
+ # dtu-env
31
+
32
+ DTU course environment manager. Interactive TUI to browse and install conda environments for DTU courses.
33
+
34
+ ## Usage
35
+
36
+ ```bash
37
+ dtu-env
38
+ ```
39
+
40
+ This launches an interactive terminal interface where you can:
41
+
42
+ 1. See your currently installed conda environments
43
+ 2. Browse available DTU course environments (fetched from GitHub)
44
+ 3. Filter/search by course number, name, or semester
45
+ 4. Multi-select environments and install them
46
+
47
+ ## Install
48
+
49
+ ```bash
50
+ pip install dtu-env
51
+ ```
52
+
53
+ Or with conda (once available on conda-forge):
54
+
55
+ ```bash
56
+ conda install dtu-env
57
+ ```
58
+
59
+ ## How it works
60
+
61
+ Course environment definitions (YAML files) are maintained in the
62
+ [dtudk/pythonsupport-page](https://github.com/dtudk/pythonsupport-page) repository.
63
+ `dtu-env` fetches these at runtime and uses `mamba`/`conda` to create the environments.
@@ -0,0 +1,34 @@
1
+ # dtu-env
2
+
3
+ DTU course environment manager. Interactive TUI to browse and install conda environments for DTU courses.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ dtu-env
9
+ ```
10
+
11
+ This launches an interactive terminal interface where you can:
12
+
13
+ 1. See your currently installed conda environments
14
+ 2. Browse available DTU course environments (fetched from GitHub)
15
+ 3. Filter/search by course number, name, or semester
16
+ 4. Multi-select environments and install them
17
+
18
+ ## Install
19
+
20
+ ```bash
21
+ pip install dtu-env
22
+ ```
23
+
24
+ Or with conda (once available on conda-forge):
25
+
26
+ ```bash
27
+ conda install dtu-env
28
+ ```
29
+
30
+ ## How it works
31
+
32
+ Course environment definitions (YAML files) are maintained in the
33
+ [dtudk/pythonsupport-page](https://github.com/dtudk/pythonsupport-page) repository.
34
+ `dtu-env` fetches these at runtime and uses `mamba`/`conda` to create the environments.
@@ -0,0 +1,42 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "dtu-env"
7
+ version = "0.1.0"
8
+ description = "DTU course environment manager — install and manage conda environments for DTU courses"
9
+ readme = "README.md"
10
+ license = "BSD-3-Clause"
11
+ requires-python = ">=3.10"
12
+ authors = [
13
+ { name = "DTU Python Support", email = "pythonsupport@dtu.dk" },
14
+ ]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Environment :: Console",
18
+ "Intended Audience :: Education",
19
+ "License :: OSI Approved :: BSD License",
20
+ "Operating System :: OS Independent",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Topic :: Education",
27
+ "Topic :: System :: Installation/Setup",
28
+ ]
29
+ dependencies = [
30
+ "textual>=0.80.0",
31
+ "rich>=13.0",
32
+ "pyyaml>=6.0",
33
+ "requests>=2.28.0",
34
+ ]
35
+
36
+ [project.scripts]
37
+ dtu-env = "dtu_env.cli:main"
38
+
39
+ [project.urls]
40
+ Homepage = "https://pythonsupport.dtu.dk"
41
+ Repository = "https://github.com/philipnickel/dtu-env"
42
+ "Bug Tracker" = "https://github.com/philipnickel/dtu-env/issues"
@@ -0,0 +1,48 @@
1
+ context:
2
+ version: "0.1.0"
3
+
4
+ package:
5
+ name: dtu-env
6
+ version: ${{ version }}
7
+
8
+ source:
9
+ url: https://pypi.org/packages/source/d/dtu-env/dtu_env-${{ version }}.tar.gz
10
+ sha256: PLACEHOLDER
11
+
12
+ build:
13
+ noarch: python
14
+ script: python -m pip install . -vv --no-deps --no-build-isolation
15
+ python:
16
+ entry_points:
17
+ - dtu-env = dtu_env.cli:main
18
+
19
+ requirements:
20
+ host:
21
+ - python >=3.10
22
+ - pip
23
+ - hatchling
24
+ run:
25
+ - python >=3.10
26
+ - textual >=0.80.0
27
+ - rich >=13.0
28
+ - pyyaml >=6.0
29
+ - requests >=2.28.0
30
+
31
+ tests:
32
+ - python:
33
+ imports:
34
+ - dtu_env
35
+ - dtu_env.cli
36
+ - dtu_env.api
37
+ - dtu_env.tui
38
+ pip_check: true
39
+
40
+ about:
41
+ homepage: https://pythonsupport.dtu.dk
42
+ license: BSD-3-Clause
43
+ license_file: LICENSE
44
+ summary: DTU course environment manager
45
+ description: |
46
+ Interactive TUI tool for browsing and installing course-specific
47
+ conda environments for DTU (Technical University of Denmark) courses.
48
+ repository: https://github.com/philipnickel/dtu-env
@@ -0,0 +1,3 @@
1
+ """dtu-env: DTU course environment manager."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ """Allow running as `python -m dtu_env`."""
2
+
3
+ from dtu_env.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,65 @@
1
+ """Fetch course environment data from GitHub."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import requests
8
+ import yaml
9
+
10
+ from dtu_env.config import GITHUB_API_URL, GITHUB_RAW_URL
11
+ from dtu_env.models import CourseEnvironment
12
+
13
+
14
+ def _api_headers() -> dict[str, str]:
15
+ """build headers for GitHub API requests, using token if available"""
16
+ headers = {"Accept": "application/vnd.github.v3+json"}
17
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
18
+ if token:
19
+ headers["Authorization"] = f"token {token}"
20
+ return headers
21
+
22
+
23
+ def fetch_environment_list() -> list[str]:
24
+ """fetch the list of .yml filenames from the GitHub environments directory"""
25
+ response = requests.get(GITHUB_API_URL, headers=_api_headers(), timeout=15)
26
+ response.raise_for_status()
27
+ entries = response.json()
28
+ return sorted(
29
+ entry["name"]
30
+ for entry in entries
31
+ if isinstance(entry, dict) and entry.get("name", "").endswith(".yml")
32
+ )
33
+
34
+
35
+ def fetch_environment_yaml(filename: str) -> dict:
36
+ """fetch and parse a single environment YAML file via raw.githubusercontent.com"""
37
+ url = f"{GITHUB_RAW_URL}/{filename}"
38
+ response = requests.get(url, timeout=15)
39
+ response.raise_for_status()
40
+ return yaml.safe_load(response.text)
41
+
42
+
43
+ def parse_environment(data: dict, filename: str) -> CourseEnvironment:
44
+ """parse a YAML dict into a CourseEnvironment"""
45
+ meta = data.get("metadata", {})
46
+ return CourseEnvironment(
47
+ name=str(data.get("name", filename.removesuffix(".yml"))),
48
+ course_number=meta.get("course_number", ""),
49
+ course_full_name=meta.get("course_full_name", ""),
50
+ course_year=meta.get("course_year", ""),
51
+ course_semester=meta.get("course_semester", ""),
52
+ channels=data.get("channels", []),
53
+ dependencies=data.get("dependencies", []),
54
+ filename=filename,
55
+ )
56
+
57
+
58
+ def fetch_all_environments() -> list[CourseEnvironment]:
59
+ """fetch and parse all available course environments"""
60
+ filenames = fetch_environment_list()
61
+ environments = []
62
+ for filename in filenames:
63
+ data = fetch_environment_yaml(filename)
64
+ environments.append(parse_environment(data, filename))
65
+ return environments
@@ -0,0 +1,34 @@
1
+ """CLI entry point for dtu-env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from dtu_env import __version__
8
+
9
+
10
+ def main() -> int:
11
+ # Only handle --version / --help, everything else is the TUI
12
+ if len(sys.argv) > 1 and sys.argv[1] in ("-V", "--version"):
13
+ print(f"dtu-env {__version__}")
14
+ return 0
15
+ if len(sys.argv) > 1 and sys.argv[1] in ("-h", "--help"):
16
+ print("dtu-env — DTU Course Environment Manager")
17
+ print()
18
+ print("Usage: dtu-env")
19
+ print()
20
+ print("Launches an interactive browser to view installed")
21
+ print("environments and install new course environments.")
22
+ print()
23
+ print("Options:")
24
+ print(" -V, --version Show version and exit")
25
+ print(" -h, --help Show this help and exit")
26
+ return 0
27
+
28
+ from dtu_env.tui import run_tui
29
+ run_tui()
30
+ return 0
31
+
32
+
33
+ if __name__ == "__main__":
34
+ sys.exit(main())
@@ -0,0 +1,17 @@
1
+ """Configuration constants for dtu-env."""
2
+
3
+ # GitHub repository serving course environment YAML files
4
+ GITHUB_USER = "dtudk"
5
+ GITHUB_REPO = "pythonsupport-page"
6
+ GITHUB_BRANCH = "main"
7
+ GITHUB_ENV_DIR = "docs/_static/environments"
8
+
9
+ # Constructed URLs
10
+ GITHUB_API_URL = (
11
+ f"https://api.github.com/repos/{GITHUB_USER}/{GITHUB_REPO}"
12
+ f"/contents/{GITHUB_ENV_DIR}?ref={GITHUB_BRANCH}"
13
+ )
14
+ GITHUB_RAW_URL = (
15
+ f"https://raw.githubusercontent.com/{GITHUB_USER}/{GITHUB_REPO}"
16
+ f"/{GITHUB_BRANCH}/{GITHUB_ENV_DIR}"
17
+ )
@@ -0,0 +1,67 @@
1
+ """Install conda environments for DTU courses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from rich.console import Console
10
+
11
+ from dtu_env.config import GITHUB_RAW_URL
12
+ from dtu_env.models import CourseEnvironment
13
+ from dtu_env.utils import find_conda_executable
14
+
15
+ console = Console()
16
+
17
+
18
+ def install_environment(env: CourseEnvironment) -> bool:
19
+ """install a course environment using mamba/conda"""
20
+ exe = find_conda_executable()
21
+ if not exe:
22
+ console.print(
23
+ "[red]Error:[/red] No conda or mamba executable found. "
24
+ "Is Miniforge3 installed and on your PATH?"
25
+ )
26
+ return False
27
+
28
+ exe_name = Path(exe).stem
29
+ url = f"{GITHUB_RAW_URL}/{env.filename}"
30
+
31
+ console.print(f"\nInstalling [bold cyan]{env.name}[/bold cyan] "
32
+ f"({env.course_full_name})...")
33
+ console.print(f"Using: [dim]{exe_name}[/dim]")
34
+ console.print(f"Source: [dim]{url}[/dim]\n")
35
+
36
+ # Download the YAML to a temp file so conda can read it
37
+ import requests
38
+ response = requests.get(url, timeout=15)
39
+ response.raise_for_status()
40
+
41
+ with tempfile.NamedTemporaryFile(
42
+ mode="w", suffix=".yml", delete=False, prefix=f"dtu-env-{env.name}-"
43
+ ) as f:
44
+ f.write(response.text)
45
+ tmp_path = f.name
46
+
47
+ try:
48
+ cmd = [exe, "env", "create", "-f", tmp_path, "--yes"]
49
+ console.print(f"Running: [dim]{' '.join(cmd)}[/dim]\n")
50
+
51
+ result = subprocess.run(
52
+ cmd,
53
+ text=True,
54
+ check=False,
55
+ )
56
+
57
+ if result.returncode == 0:
58
+ console.print(
59
+ f"\n[green]Success![/green] Environment [bold]{env.name}[/bold] installed."
60
+ )
61
+ console.print(f"Activate it with: [bold cyan]conda activate {env.name}[/bold cyan]")
62
+ return True
63
+ else:
64
+ console.print(f"\n[red]Error:[/red] Environment creation failed (exit code {result.returncode}).")
65
+ return False
66
+ finally:
67
+ Path(tmp_path).unlink(missing_ok=True)
@@ -0,0 +1,29 @@
1
+ """Data models for course environments."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class CourseEnvironment:
10
+ """a single course environment parsed from a YAML file"""
11
+
12
+ name: str
13
+ course_number: str
14
+ course_full_name: str
15
+ course_year: str
16
+ course_semester: str
17
+ channels: list[str] = field(default_factory=list)
18
+ dependencies: list[str] = field(default_factory=list)
19
+ filename: str = ""
20
+
21
+ @property
22
+ def display_name(self) -> str:
23
+ """human-readable display string"""
24
+ return f"{self.course_number} - {self.course_full_name} ({self.course_semester} {self.course_year})"
25
+
26
+ @property
27
+ def short_label(self) -> str:
28
+ """short label for menus"""
29
+ return f"[bold]{self.name}[/bold] {self.course_full_name}"
@@ -0,0 +1,355 @@
1
+ """Textual TUI for interactive course environment management."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from textual import work
6
+ from textual.app import App, ComposeResult
7
+ from textual.binding import Binding
8
+ from textual.containers import Horizontal, Vertical, VerticalScroll
9
+ from textual.screen import Screen
10
+ from textual.widgets import (
11
+ Button,
12
+ Checkbox,
13
+ Footer,
14
+ Header,
15
+ Input,
16
+ Label,
17
+ ListItem,
18
+ ListView,
19
+ Static,
20
+ )
21
+
22
+ from dtu_env.api import fetch_all_environments
23
+ from dtu_env.installer import install_environment
24
+ from dtu_env.models import CourseEnvironment
25
+ from dtu_env.utils import get_installed_environments
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Home screen — shows installed environments
30
+ # ---------------------------------------------------------------------------
31
+
32
+ class HomeScreen(Screen):
33
+ """main screen showing installed environments"""
34
+
35
+ CSS = """
36
+ #home-content {
37
+ padding: 1 2;
38
+ }
39
+ #installed-title {
40
+ text-style: bold;
41
+ margin-bottom: 1;
42
+ }
43
+ #installed-list {
44
+ height: 1fr;
45
+ margin-bottom: 1;
46
+ }
47
+ #install-btn {
48
+ margin-top: 1;
49
+ }
50
+ .env-item {
51
+ padding: 0 1;
52
+ }
53
+ #home-status {
54
+ dock: bottom;
55
+ height: 1;
56
+ background: $accent;
57
+ color: $text;
58
+ padding: 0 1;
59
+ }
60
+ """
61
+
62
+ BINDINGS = [
63
+ Binding("i", "install_new", "Install new"),
64
+ Binding("r", "refresh", "Refresh"),
65
+ Binding("q", "quit_app", "Quit"),
66
+ ]
67
+
68
+ def compose(self) -> ComposeResult:
69
+ yield Header()
70
+ with VerticalScroll(id="home-content"):
71
+ yield Label("Installed Conda Environments", id="installed-title")
72
+ yield ListView(id="installed-list")
73
+ yield Button("Install course environments", id="install-btn", variant="primary")
74
+ yield Static("[dim]i Install new | r Refresh | q Quit[/dim]", id="home-status")
75
+ yield Footer()
76
+
77
+ def on_mount(self) -> None:
78
+ self.refresh_installed()
79
+
80
+ @work(thread=True)
81
+ def refresh_installed(self) -> None:
82
+ self.app.call_from_thread(
83
+ self.query_one("#home-status", Static).update,
84
+ "Loading installed environments...",
85
+ )
86
+ installed = get_installed_environments()
87
+ self.app.call_from_thread(self._populate_installed, installed)
88
+
89
+ def _populate_installed(self, envs: list[str]) -> None:
90
+ lv = self.query_one("#installed-list", ListView)
91
+ lv.clear()
92
+ if not envs:
93
+ lv.append(ListItem(Label("[dim]No environments found[/dim]")))
94
+ else:
95
+ for name in envs:
96
+ lv.append(ListItem(Label(f" {name}", classes="env-item")))
97
+ self.query_one("#home-status", Static).update(
98
+ f"[dim]{len(envs)} environments installed | i Install new | r Refresh | q Quit[/dim]"
99
+ )
100
+
101
+ def on_button_pressed(self, event: Button.Pressed) -> None:
102
+ if event.button.id == "install-btn":
103
+ self.app.push_screen(InstallScreen())
104
+
105
+ def action_install_new(self) -> None:
106
+ self.app.push_screen(InstallScreen())
107
+
108
+ def action_refresh(self) -> None:
109
+ self.refresh_installed()
110
+
111
+ def action_quit_app(self) -> None:
112
+ self.app.exit()
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Install screen — fetch available envs, multi-select, install
117
+ # ---------------------------------------------------------------------------
118
+
119
+ class EnvCheckbox(Horizontal):
120
+ """a checkbox row for a course environment"""
121
+
122
+ DEFAULT_CSS = """
123
+ EnvCheckbox {
124
+ height: 1;
125
+ padding: 0 1;
126
+ }
127
+ EnvCheckbox .env-name {
128
+ width: 16;
129
+ text-style: bold;
130
+ color: $text;
131
+ }
132
+ EnvCheckbox .env-course {
133
+ width: 1fr;
134
+ }
135
+ EnvCheckbox .env-semester {
136
+ width: 20;
137
+ color: $text-muted;
138
+ }
139
+ """
140
+
141
+ def __init__(self, env: CourseEnvironment) -> None:
142
+ super().__init__()
143
+ self.env = env
144
+
145
+ def compose(self) -> ComposeResult:
146
+ yield Checkbox(self.env.name, value=False)
147
+ yield Label(self.env.course_full_name, classes="env-course")
148
+ yield Label(
149
+ f"{self.env.course_semester} {self.env.course_year}",
150
+ classes="env-semester",
151
+ )
152
+
153
+
154
+ class InstallScreen(Screen):
155
+ """screen for browsing and installing course environments"""
156
+
157
+ CSS = """
158
+ #install-content {
159
+ padding: 1 2;
160
+ }
161
+ #install-title {
162
+ text-style: bold;
163
+ margin-bottom: 1;
164
+ }
165
+ #search {
166
+ margin-bottom: 1;
167
+ }
168
+ #env-scroll {
169
+ height: 1fr;
170
+ margin-bottom: 1;
171
+ }
172
+ #btn-bar {
173
+ height: 3;
174
+ align-horizontal: left;
175
+ }
176
+ #btn-bar Button {
177
+ margin-right: 1;
178
+ }
179
+ #install-status {
180
+ dock: bottom;
181
+ height: 1;
182
+ background: $accent;
183
+ color: $text;
184
+ padding: 0 1;
185
+ }
186
+ """
187
+
188
+ BINDINGS = [
189
+ Binding("escape", "go_back", "Back"),
190
+ Binding("a", "select_all", "Select all"),
191
+ Binding("n", "select_none", "Select none"),
192
+ ]
193
+
194
+ environments: list[CourseEnvironment] = []
195
+ installed_names: set[str] = set()
196
+
197
+ def compose(self) -> ComposeResult:
198
+ yield Header()
199
+ with Vertical(id="install-content"):
200
+ yield Label("Install Course Environments", id="install-title")
201
+ yield Input(
202
+ placeholder="Filter by course number, name, or semester...",
203
+ id="search",
204
+ )
205
+ yield VerticalScroll(id="env-scroll")
206
+ with Horizontal(id="btn-bar"):
207
+ yield Button("Install selected", id="do-install", variant="primary")
208
+ yield Button("Back", id="go-back", variant="default")
209
+ yield Static("[dim]Loading...[/dim]", id="install-status")
210
+ yield Footer()
211
+
212
+ def on_mount(self) -> None:
213
+ self.fetch_environments()
214
+
215
+ @work(thread=True)
216
+ def fetch_environments(self) -> None:
217
+ self.app.call_from_thread(
218
+ self.query_one("#install-status", Static).update,
219
+ "Fetching available environments from GitHub...",
220
+ )
221
+ try:
222
+ envs = fetch_all_environments()
223
+ installed = get_installed_environments()
224
+ self.app.call_from_thread(self._populate, envs, set(installed))
225
+ except Exception as e:
226
+ self.app.call_from_thread(
227
+ self.query_one("#install-status", Static).update,
228
+ f"[red]Error: {e}[/red]",
229
+ )
230
+
231
+ def _populate(self, envs: list[CourseEnvironment], installed: set[str]) -> None:
232
+ self.environments = envs
233
+ self.installed_names = installed
234
+ self._render_list(envs)
235
+ self.query_one("#install-status", Static).update(
236
+ f"[dim]{len(envs)} available | a Select all | n None | Esc Back[/dim]"
237
+ )
238
+
239
+ def _render_list(self, envs: list[CourseEnvironment]) -> None:
240
+ scroll = self.query_one("#env-scroll", VerticalScroll)
241
+ scroll.remove_children()
242
+ for env in envs:
243
+ row = EnvCheckbox(env)
244
+ scroll.mount(row)
245
+ # Pre-check if already installed
246
+ if env.name in self.installed_names:
247
+ cb = row.query_one(Checkbox)
248
+ cb.value = True
249
+ cb.disabled = True
250
+ cb.label = f"{env.name} [dim](installed)[/dim]"
251
+
252
+ def on_input_changed(self, event: Input.Changed) -> None:
253
+ query = event.value.strip().lower()
254
+ if not query:
255
+ filtered = self.environments
256
+ else:
257
+ filtered = [
258
+ env for env in self.environments
259
+ if query in env.name.lower()
260
+ or query in env.course_number.lower()
261
+ or query in env.course_full_name.lower()
262
+ or query in env.course_semester.lower()
263
+ or query in env.course_year.lower()
264
+ ]
265
+ self._render_list(filtered)
266
+
267
+ def _get_selected_envs(self) -> list[CourseEnvironment]:
268
+ selected = []
269
+ for row in self.query(EnvCheckbox):
270
+ cb = row.query_one(Checkbox)
271
+ if cb.value and not cb.disabled:
272
+ selected.append(row.env)
273
+ return selected
274
+
275
+ def on_button_pressed(self, event: Button.Pressed) -> None:
276
+ if event.button.id == "do-install":
277
+ self._install_selected()
278
+ elif event.button.id == "go-back":
279
+ self.app.pop_screen()
280
+
281
+ def _install_selected(self) -> None:
282
+ selected = self._get_selected_envs()
283
+ if not selected:
284
+ self.query_one("#install-status", Static).update(
285
+ "[yellow]No environments selected[/yellow]"
286
+ )
287
+ return
288
+ self._do_install(selected)
289
+
290
+ @work(thread=True)
291
+ def _do_install(self, envs: list[CourseEnvironment]) -> None:
292
+ total = len(envs)
293
+ succeeded = 0
294
+ failed = 0
295
+
296
+ for i, env in enumerate(envs, 1):
297
+ self.app.call_from_thread(
298
+ self.query_one("#install-status", Static).update,
299
+ f"[yellow]Installing {env.name} ({i}/{total})...[/yellow]",
300
+ )
301
+
302
+ with self.app.suspend():
303
+ success = install_environment(env)
304
+
305
+ if success:
306
+ succeeded += 1
307
+ self.app.call_from_thread(self.installed_names.add, env.name)
308
+ else:
309
+ failed += 1
310
+
311
+ # Refresh the checkboxes to show newly installed
312
+ self.app.call_from_thread(self._render_list, self.environments)
313
+
314
+ summary = f"[green]{succeeded} installed[/green]"
315
+ if failed:
316
+ summary += f", [red]{failed} failed[/red]"
317
+ self.app.call_from_thread(
318
+ self.query_one("#install-status", Static).update,
319
+ f"Done: {summary} | Esc to go back",
320
+ )
321
+
322
+ def action_go_back(self) -> None:
323
+ self.app.pop_screen()
324
+
325
+ def action_select_all(self) -> None:
326
+ for row in self.query(EnvCheckbox):
327
+ cb = row.query_one(Checkbox)
328
+ if not cb.disabled:
329
+ cb.value = True
330
+
331
+ def action_select_none(self) -> None:
332
+ for row in self.query(EnvCheckbox):
333
+ cb = row.query_one(Checkbox)
334
+ if not cb.disabled:
335
+ cb.value = False
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Main app
340
+ # ---------------------------------------------------------------------------
341
+
342
+ class DtuEnvApp(App):
343
+ """DTU course environment manager"""
344
+
345
+ TITLE = "DTU Course Environments"
346
+ SUB_TITLE = "dtu-env"
347
+
348
+ def on_mount(self) -> None:
349
+ self.push_screen(HomeScreen())
350
+
351
+
352
+ def run_tui() -> None:
353
+ """launch the interactive TUI"""
354
+ app = DtuEnvApp()
355
+ app.run()
@@ -0,0 +1,37 @@
1
+ """Utility helpers for dtu-env."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+
8
+
9
+ def find_conda_executable() -> str | None:
10
+ """find mamba or conda executable, preferring mamba"""
11
+ for name in ("mamba", "conda"):
12
+ path = shutil.which(name)
13
+ if path:
14
+ return path
15
+ return None
16
+
17
+
18
+ def get_installed_environments() -> list[str]:
19
+ """return list of installed conda environment names"""
20
+ exe = find_conda_executable()
21
+ if not exe:
22
+ return []
23
+ result = subprocess.run(
24
+ [exe, "env", "list", "--json"],
25
+ capture_output=True,
26
+ text=True,
27
+ check=False,
28
+ )
29
+ if result.returncode != 0:
30
+ return []
31
+ import json
32
+ data = json.loads(result.stdout)
33
+ envs = []
34
+ for env_path in data.get("envs", []):
35
+ name = env_path.rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
36
+ envs.append(name)
37
+ return envs