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.
- dtu_env-0.1.0/.github/workflows/publish.yml +36 -0
- dtu_env-0.1.0/.gitignore +5 -0
- dtu_env-0.1.0/LICENSE +29 -0
- dtu_env-0.1.0/PKG-INFO +63 -0
- dtu_env-0.1.0/README.md +34 -0
- dtu_env-0.1.0/pyproject.toml +42 -0
- dtu_env-0.1.0/recipe.yaml +48 -0
- dtu_env-0.1.0/src/dtu_env/__init__.py +3 -0
- dtu_env-0.1.0/src/dtu_env/__main__.py +6 -0
- dtu_env-0.1.0/src/dtu_env/api.py +65 -0
- dtu_env-0.1.0/src/dtu_env/cli.py +34 -0
- dtu_env-0.1.0/src/dtu_env/config.py +17 -0
- dtu_env-0.1.0/src/dtu_env/installer.py +67 -0
- dtu_env-0.1.0/src/dtu_env/models.py +29 -0
- dtu_env-0.1.0/src/dtu_env/tui.py +355 -0
- dtu_env-0.1.0/src/dtu_env/utils.py +37 -0
|
@@ -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
|
dtu_env-0.1.0/.gitignore
ADDED
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.
|
dtu_env-0.1.0/README.md
ADDED
|
@@ -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,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
|