Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 0 additions & 4 deletions .github/workflows/docs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ on:
branches:
- main
tags: ["*"]
pull_request:
branches:
- main
types: [opened, synchronize, reopened]

concurrency:
# Skip intermediate builds: always.
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ jobs:

- name: Install package
run: |
pip install uv
pip install -e ".[test]"

- name: Test with pytest
Expand Down
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ build
coverage.xml
*.egg-info
dist
.env
.env*
lcov.info
__pycache__

Expand Down
17 changes: 10 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
default_install_hook_types:
- pre-commit
- commit-msg
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
rev: v6.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-json
- id: check-yaml
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.15.0
rev: v1.18.2
hooks:
- id: mypy
additional_dependencies:
- types-pyyaml
- types-requests
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.0
rev: v0.14.3
hooks:
- id: ruff # Linter
args: [ --fix ]
- id: ruff-check # Linter
args: [ --fix, --select, "I" ]
- id: ruff-format # Formatter
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.4.1
hooks:
- id: codespell
additional_dependencies:
- tomli
- repo: https://github.com/compilerla/conventional-pre-commit
rev: v3.6.0
rev: v4.3.0
hooks:
- id: conventional-pre-commit
stages: [commit-msg]
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
install:
pip install -e ".[dev]"
pre-commit install -t pre-commit -t commit-msg
pre-commit install

lint:
pre-commit run --all-files
Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

# -- Path setup --------------------------------------------------------------

from datetime import datetime
import os
import sys
from datetime import datetime

import frame_cli

Expand Down
6 changes: 3 additions & 3 deletions frame_cli/check.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"""Check installation and API access."""

from json import JSONDecodeError
import shutil
from json import JSONDecodeError

import requests

from .config import API_URL
from .config import API_URL, REQUESTS_TIMEOUT


def check() -> None:
Expand All @@ -20,7 +20,7 @@ def check_api() -> None:

url = f"{API_URL}/healthz"
try:
response = requests.get(url)
response = requests.get(url, timeout=REQUESTS_TIMEOUT)
except Exception:
print("API is not accessible. Check the API URL.")
return
Expand Down
9 changes: 7 additions & 2 deletions frame_cli/config.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
"""Configuration variables."""

import contextlib
import os

from dotenv import load_dotenv

load_dotenv()
with contextlib.redirect_stdout(None), contextlib.redirect_stderr(None):
load_dotenv()

LOGGING_LEVEL = os.getenv("FRAME_CLI_LOGGING_LEVEL", "INFO")
API_URL = os.getenv("FRAME_CLI_API_URL", "https://frame-dev.epfl.ch/api/")
Expand All @@ -18,4 +20,7 @@
FRAME_REPO_OWNER = "CHANGE-EPFL"
FRAME_REPO_NAME = "frame-project"
FRAME_REPO = f"{FRAME_REPO_OWNER}/{FRAME_REPO_NAME}"
EXTERNAL_REFERENCES_PATH = os.path.join("backend", "api", "metadata_files", "external_references.yaml")
FRAME_PYTHON_VERSION = "3.10"
METADATA_DIR_PATH = os.path.join("backend", "api", "metadata_files")
EXTERNAL_REFERENCES_PATH = os.path.join(METADATA_DIR_PATH, "external_references.yaml")
REQUESTS_TIMEOUT = 10 # seconds
3 changes: 2 additions & 1 deletion frame_cli/downloaders/zenodo.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
)
from rich.status import Status

from ..config import REQUESTS_TIMEOUT
from ..logging import logger
from .downloader import Downloader

Expand Down Expand Up @@ -56,7 +57,7 @@ def download(
url = f"https://{host}/api/records/{dataset_id}"

# Scan record
response = requests.get(url)
response = requests.get(url, timeout=REQUESTS_TIMEOUT)
_check_request_response(response, f'getting Zenodo record "{dataset_id}"')
record = response.json()

Expand Down
57 changes: 57 additions & 0 deletions frame_cli/environment_managers/conda.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Module containing the CondaEnvironmentManager class."""

import os
import subprocess

from rich.console import Console
from rich.panel import Panel

from .environment_manager import EnvironmentManager


class CondaEnvironmentManager(EnvironmentManager):
"""Environment manager for Conda."""

type = "conda"

def setup(self, destination: str, file_paths: list[str], *args, **kwargs) -> None:
"""Set up the environment for the hybrid model.

Args:
destination (str): Hybrid model destination directory where the environment is set up.
file_paths (list[str]): List of paths to files that describe the environment.
"""
os.chdir(destination)
console = Console()

conda_commands = ["conda", "mamba", "micromamba"]
conda_command = None

for conda_command in conda_commands:
try:
subprocess.run([conda_command, "--version"], check=True, capture_output=True) # type: ignore
break
except (subprocess.CalledProcessError, FileNotFoundError):
conda_command = None

if conda_command is None:
console.print(
"No Conda (or equivalent) installation found. Please install Conda, Mamba, or Micromamba to create an environment for this model."
)
return

console.print("Setting up Conda environment...")
for file_path in file_paths:
if not os.path.isfile(file_path):
console.print(f"File {file_path} does not exist. Skipping...")
continue
if file_path.endswith((".yml", ".yaml")):
subprocess.run([conda_command, "env", "create", "-f", file_path, "--prefix", "./.venv", "-y"])
console.print(f"Conda environment setup from {file_path} complete. Skipping remaining files...")
break
else:
console.print(f"Unsupported file {file_path}. Skipping...")

console.print("Conda environment setup complete. Activate it from the model's root directory with")
activation_message = f"cd {destination}\n{conda_command} activate ./.venv"
console.print(Panel(activation_message))
39 changes: 39 additions & 0 deletions frame_cli/environment_managers/environment_manager.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
"""Module containing the EnvironmentManager abstract base class."""

import importlib
import inspect
import os
import pkgutil
from abc import ABC, abstractmethod


Expand All @@ -16,3 +20,38 @@ def setup(self, destination: str, file_paths: list[str], *args, **kwargs) -> Non
destination (str): Hybrid model destination directory where the environment is set up.
file_paths (list[str]): List of paths to files that describe the environment.
"""


def _get_environment_manager_subclasses() -> dict[str, type[EnvironmentManager]]:
package_path = os.path.dirname(__file__)
subclasses = {}

for module_info in pkgutil.iter_modules([package_path]):
if module_info.ispkg:
continue

module = importlib.import_module(f"{__package__}.{module_info.name}")

for _, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, EnvironmentManager) and obj is not EnvironmentManager:
subclasses[obj.type] = obj

return subclasses


_environment_manager_subclasses: dict | None = None


def get_environment_manager(environment_type: str) -> EnvironmentManager | None:
"""Get an instance of environment manager for the given type."""
global _environment_manager_subclasses

if _environment_manager_subclasses is None:
_environment_manager_subclasses = _get_environment_manager_subclasses()

manager_class = _environment_manager_subclasses.get(environment_type)

if manager_class is not None:
return manager_class()

return None
39 changes: 39 additions & 0 deletions frame_cli/environment_managers/julia.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""Module containing the JuliaEnvironmentManager class."""

import os
import subprocess

from rich.console import Console
from rich.panel import Panel

from .environment_manager import EnvironmentManager


class JuliaEnvironmentManager(EnvironmentManager):
"""Environment manager for Julia."""

type = "julia"

def setup(self, destination: str, file_paths: list[str], *args, **kwargs) -> None:
"""Set up the environment for the hybrid model.

Args:
destination (str): Hybrid model destination directory where the environment is set up.
file_paths (list[str]): List of paths to files that describe the environment.
"""
os.chdir(destination)
console = Console()
console.print("Setting up Julia environment...")

try:
subprocess.run(["julia", "--version"], check=True, capture_output=True)
except (subprocess.CalledProcessError, FileNotFoundError):
console.print("Julia installation not found. Please install Julia to create an environment for this model.")
return

console.print("Setting up Julia environment using Project.toml...")
subprocess.run(["julia", "-e", 'using Pkg; Pkg.activate("."); Pkg.instantiate()'])

console.print("Julia environment setup complete. Use it from the model's root directory with")
activation_message = f"cd {destination}\njulia --project=."
console.print(Panel(activation_message))
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""Module containing the PythonRequirementsEnvironmentManager class."""
"""Module containing the PythonEnvironmentManager class."""

import os
import subprocess
Expand All @@ -10,10 +10,10 @@
from .environment_manager import EnvironmentManager


class PythonRequirementsEnvironmentManager(EnvironmentManager):
"""Environment manager for Python requirements."""
class PythonEnvironmentManager(EnvironmentManager):
"""Environment manager for Python."""

type = "python_requirements"
type = "python"

def setup(self, destination: str, file_paths: list[str], *args, **kwargs) -> None:
"""Set up the environment for the hybrid model.
Expand All @@ -27,8 +27,17 @@ def setup(self, destination: str, file_paths: list[str], *args, **kwargs) -> Non
console.print("Setting up Python environment...")
subprocess.run(["uv", "venv"])
subprocess.run(["uv", "pip", "install", "pip"])
for requirement_path in file_paths:
subprocess.run(["uv", "pip", "install", "-r", requirement_path])

for file_path in file_paths:
if not os.path.isfile(file_path):
console.print(f"File {file_path} does not exist. Skipping...")
continue
if file_path == "pyproject.toml":
subprocess.run(["uv", "pip", "install", "-e", "."])
elif file_path.endswith(".txt"):
subprocess.run(["uv", "pip", "install", "-r", file_path])
else:
console.print(f"Unsupported file {file_path}. Skipping...")

console.print("Python environment setup complete. Activate it from the model's root directory with")
activation_message = f"cd {destination}\n"
Expand Down
6 changes: 4 additions & 2 deletions frame_cli/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import yaml

from .config import FRAME_DIR_NAME, INFO_FILE_NAME
from .metadata import get_model_name, get_model_url, get_metadata_file_path
from .metadata import get_metadata_file_path, get_model_name, get_model_url


def get_home_info_path() -> str:
Expand Down Expand Up @@ -160,7 +160,9 @@ def get_github_token(use_new_token: bool = False) -> str:
global_info = get_global_info()

if "github_token" not in global_info or use_new_token:
print("Create a GitHub token with `repo` scope: https://github.com/settings/tokens/new")
print(
"Use the following link to create a classical GitHub token with `repo` and `workflow` scopes: https://github.com/settings/tokens/new"
)
github_token = input("GitHub token: ")
global_info["github_token"] = github_token
set_global_info(global_info)
Expand Down
4 changes: 2 additions & 2 deletions frame_cli/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@

from .config import FRAME_METADATA_FILE_NAME
from .metadata import (
create_metadata_file,
NotInsideGitRepositoryError,
MetadataFileAlreadyExistsError,
MetadataTemplateFetchError,
NotInsideGitRepositoryError,
create_metadata_file,
)


Expand Down
Loading
Loading