From b355f8f3784882c4a44d2658a6ceda6e45efbf73 Mon Sep 17 00:00:00 2001
From: rexwzh <1073853456@qq.com>
Date: Tue, 7 Apr 2026 06:40:07 +0800
Subject: [PATCH 1/8] refactor: align leanup cli and docs
---
AGENTS.md | 46 ++++
CONTRIBUTING.md | 104 ++++----
DEVELOP.md | 34 +++
README-en.md | 270 ---------------------
README.md | 27 ++-
docs/en/getting-started/quickstart.md | 181 --------------
docs/en/index.md | 67 ------
docs/getting-started/quickstart.md | 106 ++++----
docs/getting-started/quickstart.zh.md | 181 --------------
docs/index.md | 22 ++
docs/zh/getting-started/quickstart.md | 181 --------------
docs/zh/index.md | 67 ------
leanup/cli/__init__.py | 70 ++----
leanup/cli/elan_ops.py | 55 +++++
leanup/cli/interaction.py | 69 ++++++
leanup/cli/repo.py | 165 +++++++++----
leanup/repo/elan.py | 136 ++++++-----
leanup/repo/manager.py | 334 ++++++++++++++------------
leanup/utils/config.py | 42 ++--
mkdocs.yml | 41 +---
tests/test_cli.py | 132 +++++-----
tests/test_repo_cli.py | 82 +++++++
22 files changed, 909 insertions(+), 1503 deletions(-)
create mode 100644 AGENTS.md
create mode 100644 DEVELOP.md
delete mode 100644 README-en.md
delete mode 100644 docs/en/getting-started/quickstart.md
delete mode 100644 docs/en/index.md
delete mode 100644 docs/getting-started/quickstart.zh.md
create mode 100644 docs/index.md
delete mode 100644 docs/zh/getting-started/quickstart.md
delete mode 100644 docs/zh/index.md
create mode 100644 leanup/cli/elan_ops.py
create mode 100644 leanup/cli/interaction.py
create mode 100644 tests/test_repo_cli.py
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..9287754
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,46 @@
+# LeanUp Agent Notes
+
+## 项目信息
+
+- 主语言:Python
+- CLI 入口:`leanup`
+- 代码路径:`leanup/`
+- 文档:`README.md`、`docs/`
+
+## 目录边界
+
+```text
+leanup/
+├── cli/ # CLI 入口与编排
+├── repo/ # Lean / elan / repo 相关核心逻辑
+├── utils/ # 通用工具与配置能力
+└── const.py # 常量定义
+```
+
+## 开发规范
+
+### CLI
+
+- 命令优先,交互兜底。
+- 缺必要参数时自动进入 interactive;`-i` 强制交互,`-I` 禁止交互。
+- interactive 展示的默认值必须和最终实际执行一致。
+- 新交互统一收敛到共享 interaction 层,不在命令文件里重复写 `click.prompt` / `click.confirm` 流程。
+- CLI 文件只做接入、参数编排和输出组织,不承载主要业务实现。
+
+### 代码结构
+
+- `cli/` 负责入口编排。
+- `repo/` 负责 Lean / elan / 仓库相关核心逻辑。
+- `utils/` 只放真正通用的工具能力,不把业务逻辑塞进去。
+- 相同行为优先复用,不重复实现。
+
+### 文档
+
+- 当前以中文主文档为准,不继续维护英文平行文档。
+- 功能变更同步更新 `README.md` 和 `docs/`。
+
+### 测试
+
+- 先保证最小可运行测试覆盖。
+- 与 CLI 行为强相关的改动,测试要同步更新。
+- 新测试优先保持意图清晰、边界明确,不继续扩散历史风格。
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 05c4e5d..dc63fd4 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,20 +1,18 @@
-# 贡献指南 / Contributing Guide
+# 贡献指南
-感谢您对 LeanUp 项目的关注!我们欢迎各种形式的贡献。
+感谢您对 LeanUp 项目的关注!欢迎各种形式的贡献。
-Thank you for your interest in contributing to LeanUp! We welcome all kinds of contributions.
+## 🚀 快速开始
-## 🚀 快速开始 / Quick Start
+### 环境准备
-### 环境准备 / Environment Setup
-
-1. Fork 并克隆仓库 / Fork and clone the repository:
+1. Fork 并克隆仓库:
```bash
git clone https://github.com/yourusername/LeanUp.git
cd LeanUp
```
-2. 创建虚拟环境 / Create virtual environment:
+2. 创建虚拟环境:
```bash
python -m venv venv
source venv/bin/activate # Linux/macOS
@@ -22,101 +20,99 @@ source venv/bin/activate # Linux/macOS
venv\Scripts\activate # Windows
```
-3. 安装依赖 / Install dependencies:
+3. 安装依赖:
```bash
pip install -e .
pip install -r requirements_dev.txt
```
-## 🧪 测试 / Testing
+## 🧪 测试
-运行测试确保一切正常 / Run tests to ensure everything works:
+运行测试确保一切正常:
```bash
-# 运行所有测试 / Run all tests
+# 运行所有测试
pytest tests/ -v
-# 运行测试并生成覆盖率报告 / Run tests with coverage
+# 运行测试并生成覆盖率报告
coverage run -m pytest tests/
coverage report -m
-# 代码风格检查 / Code style check
+# 代码风格检查
ruff check .
-# 类型检查 / Type checking
+# 类型检查
mypy .
```
-## 📝 代码规范 / Code Standards
+## 📝 代码规范
-- 使用 Python 3.9+ / Use Python 3.9+
-- 遵循 PEP 8 代码风格 / Follow PEP 8 style guide
-- 添加适当的类型注解 / Add appropriate type hints
-- 为新功能编写测试 / Write tests for new features
-- 保持代码覆盖率 > 85% / Maintain code coverage > 85%
+- 使用 Python 3.9+
+- 遵循 PEP 8 代码风格
+- 添加适当的类型注解
+- 为新功能编写测试
+- 保持代码覆盖率 > 85%
+- 具体开发约束以 `AGENTS.md` 和 `DEVELOP.md` 为准
-## 🔄 提交流程 / Submission Process
+## 🔄 提交流程
-1. 创建功能分支 / Create feature branch:
+1. 创建功能分支:
```bash
git checkout -b feature/your-feature-name
```
-2. 进行更改并提交 / Make changes and commit:
+2. 进行更改并提交:
```bash
git add .
git commit -m "描述你的更改 / Describe your changes"
```
-3. 推送到你的 fork / Push to your fork:
+3. 推送到你的 fork:
```bash
git push origin feature/your-feature-name
```
-4. 创建 Pull Request / Create Pull Request
+4. 创建 Pull Request
-## 📋 提交信息格式 / Commit Message Format
+## 📋 提交信息格式
```
类型(scope): 简短描述
详细描述(可选)
-类型 / Type:
-- feat: 新功能 / new feature
-- fix: 修复 bug / bug fix
-- docs: 文档更新 / documentation update
-- style: 代码格式化 / code formatting
-- refactor: 代码重构 / code refactoring
-- test: 测试相关 / test related
-- chore: 构建/工具相关 / build/tooling related
+类型:
+- feat: 新功能
+- fix: 修复 bug
+- docs: 文档更新
+- style: 代码格式化
+- refactor: 代码重构
+- test: 测试相关
+- chore: 构建/工具相关
```
-## 🐛 报告问题 / Reporting Issues
+## 🐛 报告问题
报告 bug 或提出功能请求时,请提供:
-When reporting bugs or requesting features, please provide:
-- 操作系统和版本 / Operating system and version
-- Python 版本 / Python version
-- LeanUp 版本 / LeanUp version
-- 重现步骤 / Steps to reproduce
-- 期望行为 / Expected behavior
-- 实际行为 / Actual behavior
+- 操作系统和版本
+- Python 版本
+- LeanUp 版本
+- 重现步骤
+- 期望行为
+- 实际行为
-## 💡 功能请求 / Feature Requests
+## 💡 功能请求
-我们欢迎新功能的建议!请确保:
-We welcome suggestions for new features! Please ensure:
+欢迎新功能建议,请尽量确保:
-- 功能与项目目标一致 / Feature aligns with project goals
-- 提供清晰的用例 / Provide clear use cases
-- 考虑向后兼容性 / Consider backward compatibility
+- 功能与项目目标一致
+- 提供清晰的用例
+- 考虑向后兼容性
-## 📞 联系我们 / Contact Us
+## 📞 联系方式
-- 通过 GitHub Issues 讨论 / Discuss via GitHub Issues
-- 邮箱 / Email: leanprover@outlook.com
+- 通过 GitHub Issues 讨论
+- 邮箱:leanprover@outlook.com
-感谢您的贡献!🎉
-Thank you for your contributions! 🎉
+感谢您的贡献!
diff --git a/DEVELOP.md b/DEVELOP.md
new file mode 100644
index 0000000..a45ceb5
--- /dev/null
+++ b/DEVELOP.md
@@ -0,0 +1,34 @@
+# LeanUp 开发简版指南
+
+## 目标
+
+LeanUp 当前对齐 ChatTool 的开发方向:
+
+- CLI 风格统一
+- 入口和逻辑分层
+- 文档以中文主版本为准
+- 测试随行为变化同步更新
+
+## CLI 规范
+
+- 默认遵循:命令优先,交互兜底。
+- 缺必要参数时进入 interactive。
+- `-i` 强制 interactive。
+- `-I` 禁止 interactive,参数不足时直接报错。
+- prompt 默认值必须与最终执行值一致。
+
+## 代码规范
+
+- `leanup/cli/` 只保留命令入口和编排。
+- 具体逻辑下沉到 `repo/` 或 `utils/`。
+- 避免 CLI 文件不断膨胀成“命令 + 业务 + 交互 + 配置解析”四合一。
+
+## 文档规范
+
+- 主文档使用中文。
+- 清理重复文档和英文平行版本。
+
+## 测试规范
+
+- 改行为就改测试。
+- 优先修正现有测试到当前设计,不为旧接口加兼容层。
diff --git a/README-en.md b/README-en.md
deleted file mode 100644
index 0f1abdd..0000000
--- a/README-en.md
+++ /dev/null
@@ -1,270 +0,0 @@
-# LeanUp
-
-
-
-
-
-**A Python tool for managing Lean mathematical proof language environments**
-
-[English](README-en.md) | [简体中文](README.md)
-
-
-
-## 🎯 Features
-
-- **📦 Repository Management**: Install and manage Lean repositories with interactive configuration
-- **🌍 Cross-platform Support**: Works on Linux, macOS, and Windows
-- **📦 Easy Installation**: Quick setup via `pip install leanup`
-- **🔄 Command Proxy**: Transparent proxy for all elan commands with seamless experience
-
-## 🚀 Quick Start
-
-### Installation
-
-```bash
-# Install from PyPI
-pip install leanup
-
-# Or clone the repository and install
-git clone https://github.com/Lean-zh/LeanUp.git
-cd LeanUp
-pip install -e .
-```
-
-### Basic Usage
-
-```bash
-# View help
-leanup --help
-
-# Install elan and initialize configuration
-leanup init
-
-# Install latest stable version
-leanup install
-
-# View status
-leanup status
-
-# Proxy execute elan commands
-leanup elan --help
-leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
-```
-
-## 📖 Detailed Usage Guide
-
-### Managing Lean Toolchains
-
-After installing elan, you can use `leanup elan` commands to manage Lean toolchains:
-
-```bash
-# List all available toolchains
-leanup elan toolchain list
-
-# Install stable toolchain
-leanup elan toolchain install stable
-
-# Install nightly build
-leanup elan toolchain install leanprover/lean4:nightly
-
-# Set default toolchain
-leanup elan default stable
-
-# Update all toolchains
-leanup elan update
-
-# Show current active toolchain
-leanup elan show
-```
-
-### Repository Management
-
-```bash
-# Install Mathlib
-leanup repo install leanprover-community/mathlib4
-
-# Install specific branch or tag
-leanup repo install leanprover-community/mathlib4 -b v4.14.0
-
-# Install to custom directory
-leanup repo install Lean-zh/leanup -d /path/to/custom/dir
-
-# Control build options
-leanup repo install leanprover-community/mathlib4 --lake-build
-
-# Interactive mode
-leanup repo install leanprover-community/mathlib4 -i
-
-# Specify packages to build
-leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
-
-# List installed repositories
-leanup repo list
-
-# Search repositories in specified directory
-leanup repo list --search-dir /path/to/repos
-
-# Filter repositories by name
-leanup repo list -n mathlib
-```
-
-### Interactive Installation
-
-When using `--interactive` flag with `leanup repo install`, you can configure:
-
-- Repository name (required)
-- Base URL for repository sources
-- Branch or tag
-- Destination directory for storing repositories
-- Custom destination name
-- Whether to run `lake update` after cloning
-- Whether to run `lake build` after cloning
-- Specific build packages to compile
-- Whether to override existing directories
-
-### Programming Interface
-
-#### Using InstallConfig
-
-```python
-from leanup.repo.manager import InstallConfig
-
-# Create installation configuration
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- source="https://github.com",
- branch="main",
- dest_dir=Path("/path/to/repos"),
- dest_name="mathlib4_main",
- lake_update=True,
- lake_build=True,
- build_packages=["REPL", "REPL.Main"],
- override=False
-)
-
-# Execute installation
-config.install()
-```
-
-#### Using RepoManager
-
-```python
-from leanup.repo.manager import RepoManager
-
-# Create repository manager
-repo = RepoManager("/path/to/directory")
-
-# Clone repository
-repo.clone_from("https://github.com/owner/repo.git", branch="main")
-
-# File operations
-repo.write_file("test.txt", "Hello world")
-content = repo.read_file("test.txt")
-repo.edit_file("test.txt", "world", "universe")
-
-# List files and directories
-files = repo.list_files("*.lean")
-dirs = repo.list_dirs()
-```
-
-#### Using LeanRepo
-
-```python
-from leanup.repo.manager import LeanRepo
-
-# Create Lean repository manager
-lean_repo = LeanRepo("/path/to/lean/project")
-
-# Get project information
-info = lean_repo.get_project_info()
-print(f"Lean version: {info['lean_version']}")
-print(f"Has lakefile: {info['has_lakefile_toml']}")
-
-# Lake operations
-stdout, stderr, returncode = lean_repo.lake_init("my_project", "std")
-stdout, stderr, returncode = lean_repo.lake_update()
-stdout, stderr, returncode = lean_repo.lake_build()
-stdout, stderr, returncode = lean_repo.lake_env_lean("Main.lean")
-```
-
-## 🛠️ Development
-
-### Environment Setup
-
-```bash
-# Clone repository
-git clone https://github.com/Lean-zh/LeanUp.git
-cd LeanUp
-
-# Install development dependencies
-pip install -e ".[dev]"
-
-# Install project (editable mode)
-pip install -e .
-```
-
-### Running Tests
-
-```bash
-# Run all tests
-pytest tests/ -v
-
-# Run tests with coverage report
-coverage run -m pytest tests/
-coverage report -m
-```
-
-## ⚙️ Configuration
-
-LeanUp uses a configuration file located at `~/.leanup/config.toml`. You can customize:
-
-- Default repository source
-- Cache directory for repositories
-- Auto-installation settings for elan
-- Repository prefixes and base URLs
-
-## 🌍 Cross-platform Support
-
-LeanUp is tested on the following platforms:
-
-- **Linux**: Ubuntu 20.04+, CentOS 7+, Debian 10+
-- **macOS**: macOS 10.15+ (Intel and Apple Silicon)
-- **Windows**: Windows 10+
-
-## 📊 Project Status
-
-| Feature | Status | Description |
-|---------|--------|-------------|
-| elan Installation | ✅ | Supports automatic platform and version detection |
-| Command Proxy | ✅ | Transparent forwarding of all elan commands |
-| Repository Management | ✅ | Install and manage Lean repositories |
-| Interactive Configuration | ✅ | User-friendly setup process |
-| Cross-platform Support | ✅ | Linux/macOS/Windows |
-| Unit Tests | ✅ | Coverage > 85% |
-| CI/CD | ✅ | GitHub Actions multi-platform testing |
-
-## 🤝 Contributing
-
-Contributions are welcome! Please see the [Contributing Guide](CONTRIBUTING.md) for details.
-
-## 📝 License
-
-This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
-
-## 🔗 Related Links
-
-- [Lean Official Website](https://leanprover.github.io/)
-- [Lean Community Documentation](https://leanprover-community.github.io/)
-- [elan Toolchain Manager](https://github.com/leanprover/elan)
\ No newline at end of file
diff --git a/README.md b/README.md
index 6efe600..cc6a3d7 100644
--- a/README.md
+++ b/README.md
@@ -16,13 +16,11 @@
**一个用于管理 Lean 数学证明语言环境的 Python 工具**
-[English](README-en.md) | [简体中文](README.md)
-
## 🎯 功能特性
-- **📦 仓库管理**: 安装和管理 Lean 仓库,支持交互式配置
+- **📦 仓库管理**: 安装和管理 Lean 仓库,支持命令优先、交互兜底的配置流程
- **🌍 跨平台支持**: 支持 Linux、macOS 和 Windows
- **📦 简单易用**: 通过 `pip install leanup` 快速安装
- **🔄 命令代理**: 透明代理所有 elan 命令,无缝体验
@@ -104,9 +102,12 @@ leanup repo install Lean-zh/leanup -d /path/to/custom/dir
# 控制构建选项
leanup repo install leanprover-community/mathlib4 --lake-build
-# 交互式
+# 交互式补参
leanup repo install leanprover-community/mathlib4 -i
+# 禁止交互,参数不足时直接报错
+leanup repo install -I leanprover-community/mathlib4
+
# 指定要构建的包
leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
@@ -122,7 +123,7 @@ leanup repo list -n mathlib
### 交互式安装
-使用 `leanup repo install` 的 `--interactive` 标志时,您可以配置:
+使用 `leanup repo install -i` 时,您可以配置:
- 仓库名称(必需)
- 仓库源的基础 URL
@@ -134,6 +135,13 @@ leanup repo list -n mathlib
- 要编译的特定构建包
- 是否覆盖现有目录
+默认规则:
+
+- 命令优先,交互兜底
+- 缺必要参数时自动进入交互
+- `-i` 强制交互
+- `-I` 禁止交互,参数不足时直接报错
+
### 编程接口
#### 使用 InstallConfig
@@ -201,6 +209,11 @@ stdout, stderr, returncode = lean_repo.lake_env_lean("Main.lean")
## 🛠️ 开发
+仓库级开发规范见:
+
+- `AGENTS.md`
+- `DEVELOP.md`
+
### 环境设置
```bash
@@ -228,7 +241,7 @@ coverage report -m
## ⚙️ 配置
-LeanUp 使用位于 `~/.leanup/config.toml` 的配置文件。您可以自定义:
+LeanUp 使用位于 `~/.leanup/config.yaml` 的配置文件。您可以自定义:
- 默认仓库源
- 仓库缓存目录
@@ -267,4 +280,4 @@ LeanUp 在以下平台上经过测试:
- [Lean 官方网站](https://leanprover.github.io/)
- [Lean 社区文档](https://leanprover-community.github.io/)
-- [elan 工具链管理器](https://github.com/leanprover/elan)
\ No newline at end of file
+- [elan 工具链管理器](https://github.com/leanprover/elan)
diff --git a/docs/en/getting-started/quickstart.md b/docs/en/getting-started/quickstart.md
deleted file mode 100644
index 2eec021..0000000
--- a/docs/en/getting-started/quickstart.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# Quick Start
-
-## Installation
-
-```bash
-# Install from PyPI
-pip install leanup
-
-# Or clone the repository and install
-git clone https://github.com/Lean-zh/LeanUp.git
-cd LeanUp
-pip install -e .
-```
-
-## Basic Usage
-
-### Initialize elan
-
-```bash
-# View help
-leanup --help
-
-# Install elan
-leanup init
-
-# View current status
-leanup status
-```
-
-### Install Lean Toolchain
-
-```bash
-# Install latest stable Lean toolchain
-leanup install
-
-# Install specific Lean toolchain version
-leanup install v4.14.0
-```
-
-### Manage Toolchains
-
-```bash
-# Proxy execute elan commands
-leanup elan --help
-leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
-```
-
-### Repository Management
-
-```bash
-# Install Mathlib
-leanup repo install leanprover-community/mathlib4
-
-# Install specific branch or tag
-leanup repo install leanprover-community/mathlib4 -b v4.14.0
-
-# Install to custom directory
-leanup repo install Lean-zh/leanup -d /path/to/custom/dir
-
-# Control build options
-leanup repo install leanprover-community/mathlib4 --lake-build
-
-# Interactive mode
-leanup repo install leanprover-community/mathlib4 -i
-
-# Specify packages to build
-leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
-
-# List installed repositories
-leanup repo list
-
-# Search repositories in specified directory
-leanup repo list --search-dir /path/to/repos
-
-# Filter repositories by name
-leanup repo list -n mathlib
-```
-
-## Using InstallConfig
-
-The `InstallConfig` class provides a programmatic way to configure repository installations:
-
-```python
-from pathlib import Path
-from leanup.repo.manager import InstallConfig
-
-# Create installation configuration
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- source="https://github.com",
- branch="main",
- dest_dir=Path("/path/to/repos"),
- dest_name="mathlib4_main",
- lake_update=True,
- lake_build=True,
- build_packages=["REPL", "REPL.Main"],
- override=False
-)
-
-# Check if configuration is valid
-if config.is_valid:
- print(f"Will install to: {config.dest_path}")
-
-# Execute installation
-config.install()
-
-# Update configuration
-new_config = config.update(branch="v4.3.0", override=True)
-```
-
-## Using the RepoManager
-
-The `RepoManager` class provides functionality for managing directories and git repositories:
-
-```python
-from leanup.repo.manager import RepoManager
-
-# Create a repo manager
-repo = RepoManager("/path/to/your/directory")
-
-# Check if it's a git repository
-if repo.is_gitrepo:
- print("This is a git repository")
- status = repo.git_status()
- print(f"Current branch: {status['branch']}")
- print(f"Is dirty: {status['is_dirty']}")
-
-# Clone a repository
-success = repo.clone_from(
- "https://github.com/owner/repo.git",
- branch="main",
- depth=1
-)
-
-# File operations
-repo.write_file("test.txt", "Hello world")
-content = repo.read_file("test.txt")
-repo.edit_file("test.txt", "world", "universe", use_regex=False)
-
-# List files and directories
-files = repo.list_files("*.lean")
-dirs = repo.list_dirs()
-
-# Execute commands
-stdout, stderr, returncode = repo.execute_command(["ls", "-la"])
-```
-
-## Using LeanRepo for Lean Projects
-
-```python
-from leanup.repo.manager import LeanRepo
-
-# Create a Lean repo manager
-lean_repo = LeanRepo("/path/to/lean/project")
-
-# Get project information
-info = lean_repo.get_project_info()
-print(f"Lean version: {info['lean_version']}")
-print(f"Has lakefile.toml: {info['has_lakefile_toml']}")
-print(f"Has lakefile.lean: {info['has_lakefile_lean']}")
-print(f"Has lake-manifest.json: {info['has_lake_manifest']}")
-print(f"Build directory exists: {info['build_dir_exists']}")
-
-# Lake operations
-lean_repo.lake_init("my_project", "std", "lean")
-lean_repo.lake_update()
-lean_repo.lake_build("MyTarget")
-lean_repo.lake_env_lean("Main.lean", json=True)
-lean_repo.lake_clean()
-lean_repo.lake_test()
-
-# Install using configuration
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- lake_update=True,
- lake_build=False
-)
-lean_repo.install(config)
-```
\ No newline at end of file
diff --git a/docs/en/index.md b/docs/en/index.md
deleted file mode 100644
index 247588e..0000000
--- a/docs/en/index.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# LeanUp
-
-A Python package for managing Lean mathematical proof language environments.
-
-## Features
-
-- **🔧 elan Management**: Install and manage the Lean toolchain manager elan with a single command
-- **📦 Repository Management**: Install and manage Lean repositories with advanced configuration options
-- **🌍 Cross-platform Support**: Works on Linux, macOS, and Windows
-- **📦 Easy Installation**: Quick setup via `pip install leanup`
-
-## Quick Start
-
-Check the [Quick Start](getting-started/quickstart.md) guide to begin using LeanUp.
-
-## Command Line Interface
-
-LeanUp provides a comprehensive CLI with the following commands:
-
-### Main Commands
-
-- `leanup init` - Initialize elan installation
-- `leanup install [version]` - Install Lean toolchain version via elan
-- `leanup status` - Show current status and installed toolchains
-- `leanup elan ` - Proxy elan commands
-
-### Repository Management
-
-- `leanup repo install ` - Install Lean repositories with flexible configuration
-- `leanup repo list` - List installed repositories with filtering options
-
-#### Repository Install Options
-
-- `--source` - Repository source URL (default: https://github.com)
-- `--branch` - Branch or tag to clone
-- `--force` - Replace existing directory
-- `--dest-dir` - Destination directory
-- `--dest-name` - Custom destination name
-- `--interactive` - Interactive configuration mode
-- `--lake-update` - Run lake update after cloning (default: true)
-- `--lake-build` - Run lake build after cloning (default: true)
-- `--build-packages` - Specific packages to build
-
-## Modules
-
-### CLI Module
-
-The `leanup.cli` module provides the command-line interface:
-
-- **Main CLI**: Core commands for elan management and status monitoring
-- **Repository CLI**: Commands for managing Lean repositories with advanced options
-
-### Utils Module
-
-The `leanup.utils` module provides utility functions for the package:
-
-- `execute_command`: Execute shell commands with proper error handling and cross-platform support
-- `setup_logger`: Configure a logger with customizable output formats and color support
-
-### Repo Module
-
-The `leanup.repo` module provides repository management functionality:
-
-- `InstallConfig`: Configuration class for repository installation with validation
-- `RepoManager`: Base class for directory and git operations
-- `LeanRepo`: Specialized class for Lean project management with lake support
-- `ElanManager`: Manage elan installation and toolchain operations
\ No newline at end of file
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 2eec021..3aadb80 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -1,92 +1,92 @@
-# Quick Start
+# 快速开始
-## Installation
+## 安装
```bash
-# Install from PyPI
+# 从 PyPI 安装
pip install leanup
-# Or clone the repository and install
+# 或者克隆仓库后安装
git clone https://github.com/Lean-zh/LeanUp.git
cd LeanUp
pip install -e .
```
-## Basic Usage
+## 基础使用
-### Initialize elan
+### 初始化 elan
```bash
-# View help
+# 查看帮助
leanup --help
-# Install elan
+# 安装 elan
leanup init
-# View current status
+# 查看当前状态
leanup status
```
-### Install Lean Toolchain
+### 安装 Lean 工具链
```bash
-# Install latest stable Lean toolchain
+# 安装最新稳定版 Lean 工具链
leanup install
-# Install specific Lean toolchain version
+# 安装特定版本的 Lean 工具链
leanup install v4.14.0
```
-### Manage Toolchains
+### 管理工具链
```bash
-# Proxy execute elan commands
+# 代理执行 elan 命令
leanup elan --help
leanup elan toolchain list
leanup elan toolchain install stable
leanup elan default stable
```
-### Repository Management
+### 仓库管理
```bash
-# Install Mathlib
+# 安装 Mathlib
leanup repo install leanprover-community/mathlib4
-# Install specific branch or tag
+# 安装特定分支或标签
leanup repo install leanprover-community/mathlib4 -b v4.14.0
-# Install to custom directory
+# 安装到自定义目录
leanup repo install Lean-zh/leanup -d /path/to/custom/dir
-# Control build options
+# 控制构建选项
leanup repo install leanprover-community/mathlib4 --lake-build
-# Interactive mode
+# 交互式
leanup repo install leanprover-community/mathlib4 -i
-# Specify packages to build
+# 指定要构建的包
leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
-# List installed repositories
+# 列出已安装的仓库
leanup repo list
-# Search repositories in specified directory
+# 在指定目录中搜索仓库
leanup repo list --search-dir /path/to/repos
-# Filter repositories by name
+# 按名称过滤仓库
leanup repo list -n mathlib
```
-## Using InstallConfig
+## 使用 InstallConfig
-The `InstallConfig` class provides a programmatic way to configure repository installations:
+`InstallConfig` 类提供了编程方式配置仓库安装:
```python
from pathlib import Path
from leanup.repo.manager import InstallConfig
-# Create installation configuration
+# 创建安装配置
config = InstallConfig(
suffix="leanprover-community/mathlib4",
source="https://github.com",
@@ -99,71 +99,71 @@ config = InstallConfig(
override=False
)
-# Check if configuration is valid
+# 检查配置是否有效
if config.is_valid:
- print(f"Will install to: {config.dest_path}")
+ print(f"将安装到: {config.dest_path}")
-# Execute installation
+# 执行安装
config.install()
-# Update configuration
+# 更新配置
new_config = config.update(branch="v4.3.0", override=True)
```
-## Using the RepoManager
+## 使用 RepoManager
-The `RepoManager` class provides functionality for managing directories and git repositories:
+`RepoManager` 类提供了管理目录和 git 仓库的功能:
```python
from leanup.repo.manager import RepoManager
-# Create a repo manager
+# 创建仓库管理器
repo = RepoManager("/path/to/your/directory")
-# Check if it's a git repository
+# 检查是否为 git 仓库
if repo.is_gitrepo:
- print("This is a git repository")
+ print("这是一个 git 仓库")
status = repo.git_status()
- print(f"Current branch: {status['branch']}")
- print(f"Is dirty: {status['is_dirty']}")
+ print(f"当前分支: {status['branch']}")
+ print(f"是否有修改: {status['is_dirty']}")
-# Clone a repository
+# 克隆仓库
success = repo.clone_from(
"https://github.com/owner/repo.git",
branch="main",
depth=1
)
-# File operations
+# 文件操作
repo.write_file("test.txt", "Hello world")
content = repo.read_file("test.txt")
repo.edit_file("test.txt", "world", "universe", use_regex=False)
-# List files and directories
+# 列出文件和目录
files = repo.list_files("*.lean")
dirs = repo.list_dirs()
-# Execute commands
+# 执行命令
stdout, stderr, returncode = repo.execute_command(["ls", "-la"])
```
-## Using LeanRepo for Lean Projects
+## 使用 LeanRepo 管理 Lean 项目
```python
from leanup.repo.manager import LeanRepo
-# Create a Lean repo manager
+# 创建 Lean 仓库管理器
lean_repo = LeanRepo("/path/to/lean/project")
-# Get project information
+# 获取项目信息
info = lean_repo.get_project_info()
-print(f"Lean version: {info['lean_version']}")
-print(f"Has lakefile.toml: {info['has_lakefile_toml']}")
-print(f"Has lakefile.lean: {info['has_lakefile_lean']}")
-print(f"Has lake-manifest.json: {info['has_lake_manifest']}")
-print(f"Build directory exists: {info['build_dir_exists']}")
+print(f"Lean 版本: {info['lean_version']}")
+print(f"有 lakefile.toml: {info['has_lakefile_toml']}")
+print(f"有 lakefile.lean: {info['has_lakefile_lean']}")
+print(f"有 lake-manifest.json: {info['has_lake_manifest']}")
+print(f"构建目录存在: {info['build_dir_exists']}")
-# Lake operations
+# Lake 操作
lean_repo.lake_init("my_project", "std", "lean")
lean_repo.lake_update()
lean_repo.lake_build("MyTarget")
@@ -171,11 +171,11 @@ lean_repo.lake_env_lean("Main.lean", json=True)
lean_repo.lake_clean()
lean_repo.lake_test()
-# Install using configuration
+# 使用配置安装
config = InstallConfig(
suffix="leanprover-community/mathlib4",
lake_update=True,
lake_build=False
)
lean_repo.install(config)
-```
\ No newline at end of file
+```
diff --git a/docs/getting-started/quickstart.zh.md b/docs/getting-started/quickstart.zh.md
deleted file mode 100644
index c21de39..0000000
--- a/docs/getting-started/quickstart.zh.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# 快速开始
-
-## 安装
-
-```bash
-# 从 PyPI 安装
-pip install leanup
-
-# 或者克隆仓库后安装
-git clone https://github.com/Lean-zh/LeanUp.git
-cd LeanUp
-pip install -e .
-```
-
-## 基础使用
-
-### 初始化 elan
-
-```bash
-# 查看帮助
-leanup --help
-
-# 安装 elan
-leanup init
-
-# 查看当前状态
-leanup status
-```
-
-### 安装 Lean 工具链
-
-```bash
-# 安装最新稳定版 Lean 工具链
-leanup install
-
-# 安装特定版本的 Lean 工具链
-leanup install v4.14.0
-```
-
-### 管理工具链
-
-```bash
-# 代理执行 elan 命令
-leanup elan --help
-leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
-```
-
-### 仓库管理
-
-```bash
-# 安装 Mathlib
-leanup repo install leanprover-community/mathlib4
-
-# 安装特定分支或标签
-leanup repo install leanprover-community/mathlib4 -b v4.14.0
-
-# 安装到自定义目录
-leanup repo install Lean-zh/leanup -d /path/to/custom/dir
-
-# 控制构建选项
-leanup repo install leanprover-community/mathlib4 --lake-build
-
-# 交互式
-leanup repo install leanprover-community/mathlib4 -i
-
-# 指定要构建的包
-leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
-
-# 列出已安装的仓库
-leanup repo list
-
-# 在指定目录中搜索仓库
-leanup repo list --search-dir /path/to/repos
-
-# 按名称过滤仓库
-leanup repo list -n mathlib
-```
-
-## 使用 InstallConfig
-
-`InstallConfig` 类提供了编程方式配置仓库安装:
-
-```python
-from pathlib import Path
-from leanup.repo.manager import InstallConfig
-
-# 创建安装配置
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- source="https://github.com",
- branch="main",
- dest_dir=Path("/path/to/repos"),
- dest_name="mathlib4_main",
- lake_update=True,
- lake_build=True,
- build_packages=["REPL", "REPL.Main"],
- override=False
-)
-
-# 检查配置是否有效
-if config.is_valid:
- print(f"将安装到: {config.dest_path}")
-
-# 执行安装
-config.install()
-
-# 更新配置
-new_config = config.update(branch="v4.3.0", override=True)
-```
-
-## 使用 RepoManager
-
-`RepoManager` 类提供了管理目录和 git 仓库的功能:
-
-```python
-from leanup.repo.manager import RepoManager
-
-# 创建仓库管理器
-repo = RepoManager("/path/to/your/directory")
-
-# 检查是否为 git 仓库
-if repo.is_gitrepo:
- print("这是一个 git 仓库")
- status = repo.git_status()
- print(f"当前分支: {status['branch']}")
- print(f"是否有修改: {status['is_dirty']}")
-
-# 克隆仓库
-success = repo.clone_from(
- "https://github.com/owner/repo.git",
- branch="main",
- depth=1
-)
-
-# 文件操作
-repo.write_file("test.txt", "Hello world")
-content = repo.read_file("test.txt")
-repo.edit_file("test.txt", "world", "universe", use_regex=False)
-
-# 列出文件和目录
-files = repo.list_files("*.lean")
-dirs = repo.list_dirs()
-
-# 执行命令
-stdout, stderr, returncode = repo.execute_command(["ls", "-la"])
-```
-
-## 使用 LeanRepo 管理 Lean 项目
-
-```python
-from leanup.repo.manager import LeanRepo
-
-# 创建 Lean 仓库管理器
-lean_repo = LeanRepo("/path/to/lean/project")
-
-# 获取项目信息
-info = lean_repo.get_project_info()
-print(f"Lean 版本: {info['lean_version']}")
-print(f"有 lakefile.toml: {info['has_lakefile_toml']}")
-print(f"有 lakefile.lean: {info['has_lakefile_lean']}")
-print(f"有 lake-manifest.json: {info['has_lake_manifest']}")
-print(f"构建目录存在: {info['build_dir_exists']}")
-
-# Lake 操作
-lean_repo.lake_init("my_project", "std", "lean")
-lean_repo.lake_update()
-lean_repo.lake_build("MyTarget")
-lean_repo.lake_env_lean("Main.lean", json=True)
-lean_repo.lake_clean()
-lean_repo.lake_test()
-
-# 使用配置安装
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- lake_update=True,
- lake_build=False
-)
-lean_repo.install(config)
-```
\ No newline at end of file
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..a47c53f
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,22 @@
+# LeanUp
+
+一个用于管理 Lean 数学证明语言环境的 Python 工具。
+
+## 功能特性
+
+- `leanup init`:安装并初始化 elan
+- `leanup install [version]`:通过 elan 安装 Lean 工具链
+- `leanup status`:查看当前 elan / toolchain 状态
+- `leanup elan `:透明代理 elan 命令
+- `leanup repo install`:安装 Lean 仓库,支持命令优先、交互补参
+- `leanup repo list`:查看已安装仓库
+
+## 快速开始
+
+查看[快速开始](getting-started/quickstart.md)开始使用 LeanUp。
+
+## 开发说明
+
+- 仓库级开发规范见 `AGENTS.md` 与 `DEVELOP.md`
+- 当前以中文主文档为准,不继续维护英文平行版本
+- `repo install` 当前遵循:缺必要参数自动进入交互,`-i` 强制交互,`-I` 禁止交互
diff --git a/docs/zh/getting-started/quickstart.md b/docs/zh/getting-started/quickstart.md
deleted file mode 100644
index c21de39..0000000
--- a/docs/zh/getting-started/quickstart.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# 快速开始
-
-## 安装
-
-```bash
-# 从 PyPI 安装
-pip install leanup
-
-# 或者克隆仓库后安装
-git clone https://github.com/Lean-zh/LeanUp.git
-cd LeanUp
-pip install -e .
-```
-
-## 基础使用
-
-### 初始化 elan
-
-```bash
-# 查看帮助
-leanup --help
-
-# 安装 elan
-leanup init
-
-# 查看当前状态
-leanup status
-```
-
-### 安装 Lean 工具链
-
-```bash
-# 安装最新稳定版 Lean 工具链
-leanup install
-
-# 安装特定版本的 Lean 工具链
-leanup install v4.14.0
-```
-
-### 管理工具链
-
-```bash
-# 代理执行 elan 命令
-leanup elan --help
-leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
-```
-
-### 仓库管理
-
-```bash
-# 安装 Mathlib
-leanup repo install leanprover-community/mathlib4
-
-# 安装特定分支或标签
-leanup repo install leanprover-community/mathlib4 -b v4.14.0
-
-# 安装到自定义目录
-leanup repo install Lean-zh/leanup -d /path/to/custom/dir
-
-# 控制构建选项
-leanup repo install leanprover-community/mathlib4 --lake-build
-
-# 交互式
-leanup repo install leanprover-community/mathlib4 -i
-
-# 指定要构建的包
-leanup repo install Lean-zh/repl --build-packages "REPL,REPL.Main"
-
-# 列出已安装的仓库
-leanup repo list
-
-# 在指定目录中搜索仓库
-leanup repo list --search-dir /path/to/repos
-
-# 按名称过滤仓库
-leanup repo list -n mathlib
-```
-
-## 使用 InstallConfig
-
-`InstallConfig` 类提供了编程方式配置仓库安装:
-
-```python
-from pathlib import Path
-from leanup.repo.manager import InstallConfig
-
-# 创建安装配置
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- source="https://github.com",
- branch="main",
- dest_dir=Path("/path/to/repos"),
- dest_name="mathlib4_main",
- lake_update=True,
- lake_build=True,
- build_packages=["REPL", "REPL.Main"],
- override=False
-)
-
-# 检查配置是否有效
-if config.is_valid:
- print(f"将安装到: {config.dest_path}")
-
-# 执行安装
-config.install()
-
-# 更新配置
-new_config = config.update(branch="v4.3.0", override=True)
-```
-
-## 使用 RepoManager
-
-`RepoManager` 类提供了管理目录和 git 仓库的功能:
-
-```python
-from leanup.repo.manager import RepoManager
-
-# 创建仓库管理器
-repo = RepoManager("/path/to/your/directory")
-
-# 检查是否为 git 仓库
-if repo.is_gitrepo:
- print("这是一个 git 仓库")
- status = repo.git_status()
- print(f"当前分支: {status['branch']}")
- print(f"是否有修改: {status['is_dirty']}")
-
-# 克隆仓库
-success = repo.clone_from(
- "https://github.com/owner/repo.git",
- branch="main",
- depth=1
-)
-
-# 文件操作
-repo.write_file("test.txt", "Hello world")
-content = repo.read_file("test.txt")
-repo.edit_file("test.txt", "world", "universe", use_regex=False)
-
-# 列出文件和目录
-files = repo.list_files("*.lean")
-dirs = repo.list_dirs()
-
-# 执行命令
-stdout, stderr, returncode = repo.execute_command(["ls", "-la"])
-```
-
-## 使用 LeanRepo 管理 Lean 项目
-
-```python
-from leanup.repo.manager import LeanRepo
-
-# 创建 Lean 仓库管理器
-lean_repo = LeanRepo("/path/to/lean/project")
-
-# 获取项目信息
-info = lean_repo.get_project_info()
-print(f"Lean 版本: {info['lean_version']}")
-print(f"有 lakefile.toml: {info['has_lakefile_toml']}")
-print(f"有 lakefile.lean: {info['has_lakefile_lean']}")
-print(f"有 lake-manifest.json: {info['has_lake_manifest']}")
-print(f"构建目录存在: {info['build_dir_exists']}")
-
-# Lake 操作
-lean_repo.lake_init("my_project", "std", "lean")
-lean_repo.lake_update()
-lean_repo.lake_build("MyTarget")
-lean_repo.lake_env_lean("Main.lean", json=True)
-lean_repo.lake_clean()
-lean_repo.lake_test()
-
-# 使用配置安装
-config = InstallConfig(
- suffix="leanprover-community/mathlib4",
- lake_update=True,
- lake_build=False
-)
-lean_repo.install(config)
-```
\ No newline at end of file
diff --git a/docs/zh/index.md b/docs/zh/index.md
deleted file mode 100644
index 83a40c7..0000000
--- a/docs/zh/index.md
+++ /dev/null
@@ -1,67 +0,0 @@
-# LeanUp
-
-一个用于管理 Lean 数学证明语言环境的 Python 工具。
-
-## 功能特性
-
-- **🔧 elan 管理**: 安装和管理 Lean 工具链管理器 elan
-- **📦 仓库管理**: 安装和管理 Lean 仓库,支持高级配置选项
-- **🌍 跨平台支持**: 支持 Linux、macOS 和 Windows
-- **📦 简单易用**: 通过 `pip install leanup` 快速安装
-
-## 快速开始
-
-查看[快速开始](getting-started/quickstart.md)指南,开始使用 LeanUp。
-
-## 命令行界面
-
-LeanUp 提供了完整的 CLI,包含以下命令:
-
-### 主要命令
-
-- `leanup init` - 初始化 elan 安装
-- `leanup install [version]` - 安装 Lean 工具链版本(通过 elan)
-- `leanup status` - 显示当前状态和已安装工具链
-- `leanup elan ` - 代理 elan 命令
-
-### 仓库管理
-
-- `leanup repo install ` - 安装 Lean 仓库,支持灵活配置
-- `leanup repo list` - 列出已安装的仓库,支持过滤选项
-
-#### 仓库安装选项
-
-- `--source` - 仓库源 URL(默认:https://github.com)
-- `--branch` - 要克隆的分支或标签
-- `--force` - 替换现有目录
-- `--dest-dir` - 目标目录
-- `--dest-name` - 自定义目标名称
-- `--interactive` - 交互式配置模式
-- `--lake-update` - 克隆后运行 lake update(默认:true)
-- `--lake-build` - 克隆后运行 lake build(默认:true)
-- `--build-packages` - 要构建的特定包
-
-## 模块
-
-### CLI 模块
-
-`leanup.cli` 模块提供了命令行界面:
-
-- **主 CLI**: elan 管理和状态监控的核心命令
-- **仓库 CLI**: 管理 Lean 仓库的命令,支持高级选项
-
-### Utils 模块
-
-`leanup.utils` 模块提供了包的实用函数:
-
-- `execute_command`: 执行 shell 命令,具有适当的错误处理和跨平台支持
-- `setup_logger`: 配置日志记录器,支持自定义输出格式和颜色
-
-### Repo 模块
-
-`leanup.repo` 模块提供了仓库管理功能:
-
-- `InstallConfig`: 仓库安装的配置类,支持验证
-- `RepoManager`: 目录和 git 操作的基础类
-- `LeanRepo`: 专门用于 Lean 项目管理的类,支持 lake 操作
-- `ElanManager`: 管理 elan 安装和工具链操作
\ No newline at end of file
diff --git a/leanup/cli/__init__.py b/leanup/cli/__init__.py
index 177effa..7e8c12e 100644
--- a/leanup/cli/__init__.py
+++ b/leanup/cli/__init__.py
@@ -1,11 +1,14 @@
import click
import sys
-from pathlib import Path
-from typing import Optional
-from leanup.repo.elan import ElanManager
from leanup.utils.custom_logger import setup_logger
from leanup.cli.repo import repo
+from leanup.cli.elan_ops import (
+ init_elan,
+ install_lean_toolchain,
+ proxy_elan,
+ show_status,
+)
logger = setup_logger("leanup_cli")
@@ -21,77 +24,32 @@ def cli(ctx):
@cli.command()
def init():
"""Install latest elan"""
- # Install elan
- elan_manager = ElanManager()
- if not elan_manager.is_elan_installed():
- click.echo("Installing elan...")
- if elan_manager.install_elan():
- click.echo("✓ elan installed successfully")
- else:
- click.echo("✗ Failed to install elan", err=True)
- sys.exit(1)
- else:
- click.echo("✓ elan is already installed")
+ init_elan()
@cli.command()
-@click.argument('version', required=False)
-def install(version: Optional[str]):
+@click.argument("version", required=False)
+def install(version):
"""Install Lean toolchain version via elan"""
- elan_manager = ElanManager()
-
- if not elan_manager.is_elan_installed():
- click.echo("✗ elan is not installed, trying to install...")
- if not elan_manager.install_elan():
- click.echo("✗ Failed to install elan", err=True)
- sys.exit(1)
- click.echo("✓ elan installed successfully")
-
- if not elan_manager.install_lean(version):
- click.echo(f"✗ Failed to install Lean toolchain {version}", err=True)
- sys.exit(1)
-
- click.echo(f"✓ Lean toolchain {version} installed")
+ install_lean_toolchain(version)
@cli.command()
def status():
"""Show status information"""
- elan_manager = ElanManager()
-
- click.echo("=== LeanUp Status ===")
-
- # elan status
- if elan_manager.is_elan_installed():
- version = elan_manager.get_elan_version()
- click.echo(f"\nelan version: {version}")
- click.echo("---------------------\n")
-
- # Show toolchains
- elan_manager.proxy_elan_command(['show'])
- else:
- click.echo("elan: ✗ not installed")
+ show_status()
@cli.command(context_settings=dict(ignore_unknown_options=True))
-@click.argument('args', nargs=-1, type=click.UNPROCESSED)
+@click.argument("args", nargs=-1, type=click.UNPROCESSED)
def elan(args):
"""Proxy elan commands"""
- elan_manager = ElanManager()
-
- if not elan_manager.is_elan_installed():
- click.echo("elan is not installed. Run 'leanup init' first.", err=True)
- sys.exit(1)
-
- # Execute elan command
try:
- result = elan_manager.proxy_elan_command(list(args))
+ result = proxy_elan(list(args))
sys.exit(result)
except KeyboardInterrupt:
click.echo("\nInterrupted", err=True)
sys.exit(1)
-cli.add_command(repo)
-if __name__ == '__main__':
- cli()
\ No newline at end of file
+cli.add_command(repo)
diff --git a/leanup/cli/elan_ops.py b/leanup/cli/elan_ops.py
new file mode 100644
index 0000000..d0478c3
--- /dev/null
+++ b/leanup/cli/elan_ops.py
@@ -0,0 +1,55 @@
+from __future__ import annotations
+
+import click
+
+from leanup.repo.elan import ElanManager
+
+
+def ensure_elan_installed() -> ElanManager:
+ elan_manager = ElanManager()
+ if elan_manager.is_elan_installed():
+ return elan_manager
+
+ click.echo("Installing elan...")
+ if not elan_manager.install_elan():
+ raise click.ClickException("Failed to install elan")
+ click.echo("✓ elan installed successfully")
+ return elan_manager
+
+
+def init_elan() -> None:
+ elan_manager = ElanManager()
+ if elan_manager.is_elan_installed():
+ click.echo("✓ elan is already installed")
+ return
+ click.echo("Installing elan...")
+ if not elan_manager.install_elan():
+ raise click.ClickException("Failed to install elan")
+ click.echo("✓ elan installed successfully")
+
+
+def install_lean_toolchain(version: str | None) -> None:
+ elan_manager = ensure_elan_installed()
+ requested = version or "stable"
+ if not elan_manager.install_lean(version):
+ raise click.ClickException(f"Failed to install Lean toolchain {requested}")
+ click.echo(f"✓ Lean toolchain {requested} installed")
+
+
+def show_status() -> None:
+ elan_manager = ElanManager()
+ click.echo("=== LeanUp Status ===")
+ if not elan_manager.is_elan_installed():
+ click.echo("elan: ✗ not installed")
+ return
+ version = elan_manager.get_elan_version()
+ click.echo(f"\nelan version: {version}")
+ click.echo("---------------------\n")
+ elan_manager.proxy_elan_command(["show"])
+
+
+def proxy_elan(args: list[str]) -> int:
+ elan_manager = ElanManager()
+ if not elan_manager.is_elan_installed():
+ raise click.ClickException("elan is not installed. Run 'leanup init' first.")
+ return elan_manager.proxy_elan_command(list(args))
diff --git a/leanup/cli/interaction.py b/leanup/cli/interaction.py
new file mode 100644
index 0000000..3aca7d4
--- /dev/null
+++ b/leanup/cli/interaction.py
@@ -0,0 +1,69 @@
+from __future__ import annotations
+
+from typing import Any
+
+import click
+
+
+def normalize_interactive(interactive):
+ ctx = click.get_current_context(silent=True)
+ if ctx:
+ try:
+ if (
+ ctx.get_parameter_source("interactive")
+ == click.core.ParameterSource.DEFAULT
+ ):
+ return None
+ except Exception:
+ pass
+ return interactive
+
+
+def is_interactive_available() -> bool:
+ return (
+ click.get_text_stream("stdin").isatty()
+ and click.get_text_stream("stdout").isatty()
+ )
+
+
+def resolve_interactive_mode(interactive, auto_prompt_condition: bool):
+ interactive = normalize_interactive(interactive)
+ can_prompt = is_interactive_available()
+ force_interactive = interactive is True
+ auto_interactive = interactive is None and can_prompt and auto_prompt_condition
+ need_prompt = force_interactive or auto_interactive
+ return interactive, can_prompt, force_interactive, auto_interactive, need_prompt
+
+
+def abort_if_force_without_tty(force_interactive, can_prompt, usage):
+ if force_interactive and not can_prompt:
+ click.echo("Interactive mode was requested, but no TTY is available.", err=True)
+ click.echo(usage, err=True)
+ raise click.Abort()
+
+
+def abort_if_missing_without_tty(
+ missing_required, interactive, can_prompt, message, usage
+):
+ if missing_required and interactive is None and not can_prompt:
+ click.echo(message, err=True)
+ click.echo(usage, err=True)
+ raise click.Abort()
+
+
+def resolve_value(*candidates: Any) -> Any:
+ for candidate in candidates:
+ if candidate is None:
+ continue
+ if isinstance(candidate, str) and not candidate.strip():
+ continue
+ return candidate
+ return None
+
+
+def ask_text(message: str, default: str = "") -> str:
+ return click.prompt(message, default=default, show_default=bool(default), err=True)
+
+
+def ask_confirm(message: str, default: bool = True) -> bool:
+ return click.confirm(message, default=default, err=True)
diff --git a/leanup/cli/repo.py b/leanup/cli/repo.py
index 35f45ff..238c65c 100644
--- a/leanup/cli/repo.py
+++ b/leanup/cli/repo.py
@@ -1,16 +1,21 @@
import click
-import shutil
-import sys
from pathlib import Path
from typing import Optional
-from urllib.parse import urlparse
from leanup.const import LEANUP_CACHE_DIR
-from leanup.repo.manager import RepoManager, LeanRepo, InstallConfig
+from leanup.repo.manager import InstallConfig
+from leanup.cli.interaction import (
+ abort_if_force_without_tty,
+ abort_if_missing_without_tty,
+ ask_confirm,
+ ask_text,
+ resolve_interactive_mode,
+)
from leanup.utils.custom_logger import setup_logger
logger = setup_logger("repo_cli")
+
@click.group()
def repo():
"""Repository management commands"""
@@ -18,72 +23,130 @@ def repo():
@repo.command()
-@click.argument('suffix', required=False)
-@click.option('--source', '-s', help='Repository source', default='https://github.com')
-@click.option('--branch', '-b', help='Branch or tag to clone')
-@click.option('--force', '-f', is_flag=True, help='Replace existing directory')
-@click.option('--dest-dir', '-d', help='Destination directory', type=click.Path(path_type=Path), default=LEANUP_CACHE_DIR / "repos")
-@click.option('--dest-name', '-n', help='Destination name')
-@click.option('--interactive', '-i', is_flag=True, help='Interactive mode')
-@click.option('--lake-update', is_flag=True, help='Run lake update after cloning', default=True)
-@click.option('--lake-build', is_flag=True, help='Run lake build after cloning', default=True)
-@click.option('--build-packages', help='Packages to build after cloning')
-def install(suffix: str, source: str, branch: Optional[str], force: bool,
- dest_dir: Optional[Path], dest_name: Optional[str], interactive: bool, lake_update: bool, lake_build: bool, build_packages: str):
+@click.argument("suffix", required=False)
+@click.option("--source", "-s", help="Repository source", default="https://github.com")
+@click.option("--branch", "-b", help="Branch or tag to clone")
+@click.option("--force", "-f", is_flag=True, help="Replace existing directory")
+@click.option(
+ "--dest-dir",
+ "-d",
+ help="Destination directory",
+ type=click.Path(path_type=Path),
+ default=LEANUP_CACHE_DIR / "repos",
+)
+@click.option("--dest-name", "-n", help="Destination name")
+@click.option(
+ "--interactive/--no-interactive",
+ "-i/-I",
+ default=None,
+ help="Auto prompt on missing args, -i forces interactive, -I disables it.",
+)
+@click.option(
+ "--lake-update", is_flag=True, help="Run lake update after cloning", default=True
+)
+@click.option(
+ "--lake-build", is_flag=True, help="Run lake build after cloning", default=True
+)
+@click.option("--build-packages", help="Packages to build after cloning")
+def install(
+ suffix: str,
+ source: str,
+ branch: Optional[str],
+ force: bool,
+ dest_dir: Optional[Path],
+ dest_name: Optional[str],
+ interactive: bool,
+ lake_update: bool,
+ lake_build: bool,
+ build_packages: str,
+):
"""Install a repository"""
- if interactive:
- suffix = click.prompt("Suffix (user/repo) (required)", type=str, default=suffix)
- source = click.prompt("Repository source", type=str, default=source)
- branch = click.prompt("Branch or tag", type=str, default=branch or "")
- config = InstallConfig(
- suffix=suffix,
- source=source,
- branch=branch
+ usage = "Usage: leanup repo install [SUFFIX] [--source ] [--branch ] [--dest-dir ] [--dest-name ] [--lake-update] [--lake-build] [--build-packages ] [-i|-I]"
+ missing_required = not suffix
+ interactive, can_prompt, force_interactive, _, need_prompt = (
+ resolve_interactive_mode(
+ interactive=interactive,
+ auto_prompt_condition=missing_required,
)
- config._dest_dir = click.prompt("Destination directory", type=click.Path(path_type=Path), default=dest_dir)
- if dest_name is None:
- dest_name = config.suffix
- config._dest_name = click.prompt("Destination name", type=str, default=dest_name)
- config.lake_update = click.confirm("Run lake update after cloning?", default=lake_update)
- config.lake_build = click.confirm("Run lake build after cloning?", default=lake_build)
- config._build_packages = click.prompt("Packages to build after cloning(e.g. REPL,REPL.Main)", type=str, default=build_packages or "" )
- if config.dest_path.exists():
- config.override = click.confirm(f"Repository {config.dest_name} already exists in {config.dest_dir}. Override?", default=False)
- if not config.override:
- click.echo(f"Aborted.", err=True)
- sys.exit(1)
- else:
- config = InstallConfig(
+ )
+
+ abort_if_force_without_tty(force_interactive, can_prompt, usage)
+ abort_if_missing_without_tty(
+ missing_required=missing_required,
+ interactive=interactive,
+ can_prompt=can_prompt,
+ message="Missing required repository suffix and no TTY is available for interactive prompts.",
+ usage=usage,
+ )
+
+ if missing_required and not need_prompt:
+ raise click.ClickException("Missing required repository suffix.")
+
+ if need_prompt:
+ suffix = ask_text("Suffix (user/repo)", default=suffix or "")
+ source = ask_text("Repository source", default=source)
+ branch = ask_text("Branch or tag", default=branch or "") or None
+ dest_dir = Path(ask_text("Destination directory", default=str(dest_dir)))
+ preview = InstallConfig(
suffix=suffix,
source=source,
branch=branch,
dest_dir=dest_dir,
dest_name=dest_name,
- lake_update=lake_update,
- lake_build=lake_build,
- build_packages=build_packages,
- override=force
)
+ dest_name = ask_text("Destination name", default=dest_name or preview.dest_name)
+ lake_update = ask_confirm("Run lake update after cloning?", default=lake_update)
+ lake_build = ask_confirm("Run lake build after cloning?", default=lake_build)
+ build_packages = ask_text(
+ "Packages to build after cloning (e.g. REPL,REPL.Main)",
+ default=build_packages or "",
+ )
+
+ config = InstallConfig(
+ suffix=suffix,
+ source=source,
+ branch=branch,
+ dest_dir=dest_dir,
+ dest_name=dest_name,
+ lake_update=lake_update,
+ lake_build=lake_build,
+ build_packages=build_packages,
+ override=force,
+ )
+
+ if need_prompt and config.dest_path.exists() and not config.override:
+ config.override = ask_confirm(
+ f"Repository {config.dest_name} already exists in {config.dest_dir}. Override?",
+ default=False,
+ )
+ if not config.override:
+ click.echo("Aborted.", err=True)
+ raise click.Abort()
+
if not config.is_valid:
click.echo("Error: Repository name is required", err=True)
- sys.exit(1)
+ raise click.Abort()
config.install()
+
@repo.command()
-@click.option('--name', '-n', help='Filter by repository name')
-@click.option('--search-dir', '-s', help='Directory to search for repositories',
- type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
- default=LEANUP_CACHE_DIR / "repos")
+@click.option("--name", "-n", help="Filter by repository name")
+@click.option(
+ "--search-dir",
+ "-s",
+ help="Directory to search for repositories",
+ type=click.Path(exists=True, file_okay=False, dir_okay=True, path_type=Path),
+ default=LEANUP_CACHE_DIR / "repos",
+)
def list(name: Optional[str], search_dir: Path):
"""List repositories in the specified directory"""
-
+
if not search_dir.exists():
- click.echo(f"Directory {search_dir} doesn't exist.", err=True)
- sys.exit(1)
+ raise click.ClickException(f"Directory {search_dir} doesn't exist.")
names = [dir.name for dir in search_dir.iterdir() if dir.is_dir()]
if name:
names = [n for n in names if name in n]
-
+
for name in names:
click.echo(name)
diff --git a/leanup/repo/elan.py b/leanup/repo/elan.py
index c9de4c0..cf034b2 100644
--- a/leanup/repo/elan.py
+++ b/leanup/repo/elan.py
@@ -12,12 +12,13 @@
logger = setup_logger("elan_manager")
+
class ElanManager:
"""Elan toolchain manager"""
def __init__(self):
- self.elan_home = Path(os.environ.get('ELAN_HOME', Path.home() / '.elan'))
- self.elan_bin_dir = self.elan_home / 'bin'
+ self.elan_home = Path(os.environ.get("ELAN_HOME", Path.home() / ".elan"))
+ self.elan_bin_dir = self.elan_home / "bin"
self._elan_exe = None
@property
@@ -25,73 +26,75 @@ def elan_exe(self):
if self._elan_exe is None:
self._elan_exe = self.get_elan_executable()
return self._elan_exe
-
+
def get_elan_executable(self) -> Optional[Path]:
"""Get elan executable file path"""
- elan_exe = 'elan.exe' if OS_TYPE == 'Windows' else 'elan'
+ elan_exe = "elan.exe" if OS_TYPE == "Windows" else "elan"
elan_path = self.elan_bin_dir / elan_exe
-
+
if elan_path.exists() and elan_path.is_file():
return elan_path
-
+
# Try to find in PATH
- elan_in_path = shutil.which('elan')
+ elan_in_path = shutil.which("elan")
if elan_in_path:
return Path(elan_in_path)
-
+
return None
-
+
def is_elan_installed(self) -> bool:
"""Check if elan is installed"""
return self.elan_exe is not None
-
+
def get_elan_version(self) -> Optional[str]:
"""Get installed elan version"""
if not self.elan_exe:
return None
-
+
try:
- output, error, code = execute_command([str(self.elan_exe), '--version'])
+ output, error, code = execute_command([str(self.elan_exe), "--version"])
if code == 0:
# elan 4.0.0 (bb75b50d2 2025-01-30)
- elan_regex = re.compile(r'elan\s+(\d+\.\d+\.\d+)')
+ elan_regex = re.compile(r"elan\s+(\d+\.\d+\.\d+)")
match = elan_regex.search(output)
if match:
return match.group(1)
else:
- logger.warning(f"Failed to parse elan version from output: {output}")
+ logger.warning(
+ f"Failed to parse elan version from output: {output}"
+ )
return None
except Exception as e:
logger.error(f"Failed to get elan version: {e}")
return None
-
+
def download(self, url: str, target_path: Path) -> bool:
"""Download elan installer"""
try:
logger.info(f"Downloading elan installer: {url}")
-
+
response = requests.get(url, stream=True)
response.raise_for_status()
-
+
target_path.parent.mkdir(parents=True, exist_ok=True)
-
- with open(target_path, 'wb') as f:
+
+ with open(target_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
-
+
logger.info(f"Download completed: {target_path}")
return True
-
+
except Exception as e:
logger.error(f"Failed to download elan installer: {e}")
return False
-
+
def install_elan(self, force: bool = False) -> bool:
"""Install elan with optional version specification.
-
+
Args:
force: Force reinstall even if already installed
-
+
Returns:
bool: True if installation successful, False otherwise
"""
@@ -102,13 +105,9 @@ def install_elan(self, force: bool = False) -> bool:
try:
# Use working_directory context manager for temporary directory
with working_directory() as temp_dir:
- if OS_TYPE == 'Windows':
+ if OS_TYPE == "Windows":
# Windows uses PowerShell to run script directly from network (official recommended way)
logger.info("Installing elan via PowerShell...")
- # Set environment variables for non-interactive installation
- env = os.environ.copy()
- env['ELAN_HOME'] = str(self.elan_home)
-
# Use PowerShell to download and execute as recommended by official docs
script_content = f"""
$env:ELAN_HOME = "{self.elan_home}"
@@ -116,46 +115,53 @@ def install_elan(self, force: bool = False) -> bool:
& .\\elan-init.ps1 -y
Remove-Item "elan-init.ps1" -ErrorAction SilentlyContinue
"""
-
- cmd = ['powershell', '-ExecutionPolicy', 'Bypass', '-Command', script_content]
+
+ cmd = [
+ "powershell",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-Command",
+ script_content,
+ ]
else:
download_url = "https://elan.lean-lang.org/elan-init.sh"
# Linux/macOS use shell script installation
- installer_path = temp_dir / 'elan-init.sh'
+ installer_path = temp_dir / "elan-init.sh"
if not self.download(download_url, installer_path):
return False
-
+
logger.info("Running elan installation script...")
- # Set environment variables for non-interactive installation
- env = os.environ.copy()
- env['ELAN_HOME'] = str(self.elan_home)
-
- cmd = ['sh', str(installer_path), '-y']
+
+ cmd = ["sh", str(installer_path), "-y"]
output, error, code = execute_command(cmd, cwd=str(temp_dir))
if code != 0:
logger.error(f"Installation failed: {error}")
return False
-
+
# Verify installation
if self.is_elan_installed():
installed_version = self.get_elan_version()
- logger.info(f"elan installed successfully! Version: {installed_version}")
+ logger.info(
+ f"elan installed successfully! Version: {installed_version}"
+ )
return True
else:
- logger.error("Installation completed, but elan executable not found")
+ logger.error(
+ "Installation completed, but elan executable not found"
+ )
return False
-
+
except Exception as e:
logger.error(f"Error occurred during elan installation: {e}")
return False
-
- def install_lean(self, version:str=None)-> bool:
+
+ def install_lean(self, version: str = None) -> bool:
installed = self.get_installed_toolchains()
if version in installed:
logger.info(f"Lean toolchain {version} is already installed")
return True
- version = version or 'stable'
- cmd = ['toolchain', 'install', version]
+ version = version or "stable"
+ cmd = ["toolchain", "install", version]
result = self.proxy_elan_command(cmd)
return result == 0
@@ -164,10 +170,10 @@ def proxy_elan_command(self, args: List[str]) -> int:
if not self.elan_exe:
if not self.install_elan():
return 1
-
+
# Build complete command
cmd = [str(self.elan_exe)] + args
-
+
try:
# Pass directly to subprocess to maintain interactivity and streaming
result = subprocess.run(cmd, check=False)
@@ -175,19 +181,21 @@ def proxy_elan_command(self, args: List[str]) -> int:
except Exception as e:
logger.error(f"Failed to execute elan command: {e}")
return 1
-
+
def get_installed_toolchains(self) -> List[str]:
"""Get list of installed toolchains"""
if not self.elan_exe:
return []
-
+
try:
- output, error, code = execute_command([str(self.elan_exe), 'toolchain', 'list'])
+ output, error, code = execute_command(
+ [str(self.elan_exe), "toolchain", "list"]
+ )
if code == 0:
toolchains = []
- for line in output.strip().split('\n'):
+ for line in output.strip().split("\n"):
line = line.strip()
- if line and not line.startswith('#'):
+ if line and not line.startswith("#"):
# Remove status markers (like (default))
toolchain = line.split()[0]
toolchains.append(toolchain)
@@ -196,19 +204,19 @@ def get_installed_toolchains(self) -> List[str]:
except Exception as e:
logger.error(f"Failed to get toolchain list: {e}")
return []
-
+
def get_status_info(self) -> Dict[str, any]:
"""Get elan status information"""
info = {
- 'installed': self.is_elan_installed(),
- 'version': None,
- 'elan_home': str(self.elan_home),
- 'executable': None,
- 'toolchains': []
+ "installed": self.is_elan_installed(),
+ "version": None,
+ "elan_home": str(self.elan_home),
+ "executable": None,
+ "toolchains": [],
}
-
- if info['installed']:
- info['version'] = self.get_elan_version()
- info['executable'] = str(self.elan_exe)
- info['toolchains'] = self.get_installed_toolchains()
+
+ if info["installed"]:
+ info["version"] = self.get_elan_version()
+ info["executable"] = str(self.elan_exe)
+ info["toolchains"] = self.get_installed_toolchains()
return info
diff --git a/leanup/repo/manager.py b/leanup/repo/manager.py
index cbb9bff..2ef7b4f 100644
--- a/leanup/repo/manager.py
+++ b/leanup/repo/manager.py
@@ -1,12 +1,10 @@
-from dataclasses import dataclass
+from dataclasses import dataclass, field
import shutil
import re
from pathlib import Path
-from token import OP
from typing import Optional, Union, List, Dict, Any, Tuple
import git
import os
-import toml
from leanup.const import OS_TYPE, LEANUP_CACHE_DIR
from leanup.utils.basic import execute_command
from leanup.utils.custom_logger import setup_logger
@@ -14,26 +12,39 @@
logger = setup_logger("repo_manager")
-# Installation Config
+@dataclass
class InstallConfig:
- def __init__(self,
- suffix: Optional[str]=None,
- source: Optional[str]='https://github.com',
- url: Optional[str]=None,
- branch: Optional[str]=None,
- dest_name: Optional[str]=None,
- dest_dir: Optional[Path]=None,
- build_packages: Optional[List[str]]=None,
- lake_update: bool = False,
- lake_build:bool = False,
- override: bool = False):
+ _suffix: Optional[str] = None
+ source: str = "https://github.com"
+ _url: Optional[str] = None
+ branch: Optional[str] = None
+ _dest_name: Optional[str] = None
+ _dest_dir: Optional[Path] = None
+ _build_packages: Optional[Union[List[str], str]] = None
+ lake_update: bool = False
+ lake_build: bool = False
+ override: bool = False
+
+ def __init__(
+ self,
+ suffix: Optional[str] = None,
+ source: Optional[str] = "https://github.com",
+ url: Optional[str] = None,
+ branch: Optional[str] = None,
+ dest_name: Optional[str] = None,
+ dest_dir: Optional[Path] = None,
+ build_packages: Optional[Union[List[str], str]] = None,
+ lake_update: bool = False,
+ lake_build: bool = False,
+ override: bool = False,
+ ):
self._suffix = suffix
+ self.source = source or "https://github.com"
self._url = url
+ self.branch = branch
self._dest_name = dest_name
self._dest_dir = dest_dir
self._build_packages = build_packages
- self.branch = branch
- self.source = source
self.lake_update = lake_update
self.lake_build = lake_build
self.override = override
@@ -45,16 +56,16 @@ def url(self):
raise ValueError("suffix or url is required")
return f"{self.source}/{self._suffix}"
return self._url
-
+
@property
def suffix(self):
if self._suffix is None:
- suffix = self._url.strip().split('/')[-1]
- if suffix.endswith('.git'):
+ suffix = self._url.strip().split("/")[-1]
+ if suffix.endswith(".git"):
suffix = suffix[:-4]
return suffix
return self._suffix
-
+
@property
def dest_name(self):
if self._dest_name is None:
@@ -76,24 +87,24 @@ def build_packages(self):
if not value:
return []
if isinstance(value, str):
- return [pkg.strip() for pkg in value.strip('[]').split(',')]
+ return [pkg.strip() for pkg in value.strip("[]").split(",")]
elif isinstance(value, list):
return value
else:
return []
-
+
@property
def is_valid(self):
return self.url is not None
@property
- def dest_path(self)->Path:
+ def dest_path(self) -> Path:
return self.dest_dir / self.dest_name
-
+
def get(self, key: str, default: Any = None) -> Any:
"""Get config value"""
return getattr(self, key, default)
-
+
def update(self, **kwargs):
"""Update config"""
config = self.copy()
@@ -103,8 +114,8 @@ def update(self, **kwargs):
k = f"_{k}"
setattr(config, k, v)
return config
-
- def copy(self) -> 'InstallConfig':
+
+ def copy(self) -> "InstallConfig":
"""Copy config"""
return InstallConfig(
suffix=self._suffix,
@@ -121,24 +132,25 @@ def copy(self) -> 'InstallConfig':
def install(self):
repo = LeanRepo(self.dest_path)
- repo.install(self)
+ return repo.install(self)
+
class RepoManager:
"""Class for managing directory operations and git functionality."""
-
- def __init__(self, cwd: Union[str, Path]=None):
+
+ def __init__(self, cwd: Union[str, Path] = None):
"""Initialize with a working directory.
-
+
Args:
cwd: Working directory path
"""
if cwd is None:
- cwd = Path.cwd().resolve()
+ self.cwd = Path.cwd().resolve()
else:
self.cwd = Path(cwd).resolve()
self._git_repo = None
self._check_git_repo()
-
+
def _check_git_repo(self) -> None:
"""Check if the current directory is a git repository and initialize git.Repo if it is."""
try:
@@ -149,24 +161,26 @@ def _check_git_repo(self) -> None:
logger.debug(f"{self.cwd} is not a git repository")
except Exception as e:
logger.error(f"Error checking git repository: {e}")
-
+
@property
def is_gitrepo(self) -> bool:
"""Check if the current directory is a git repository.
-
+
Returns:
bool: True if the directory is a git repository, False otherwise
"""
return self._git_repo is not None
-
- def clone_from(self, url: str, branch: Optional[str] = None, depth: Optional[int] = None) -> bool:
+
+ def clone_from(
+ self, url: str, branch: Optional[str] = None, depth: Optional[int] = None
+ ) -> bool:
"""Clone a git repository to the current directory.
-
+
Args:
url: Git repository URL
branch: Branch to clone (optional)
depth: Depth for shallow clone (optional)
-
+
Returns:
bool: True if successful, False otherwise
"""
@@ -177,11 +191,11 @@ def clone_from(self, url: str, branch: Optional[str] = None, depth: Optional[int
cmd.extend(["--branch", branch])
if depth:
cmd.extend(["--depth", str(depth)])
-
+
# Execute clone command
self.cwd.mkdir(parents=True, exist_ok=True)
stdout, stderr, returncode = execute_command(cmd, cwd=str(self.cwd))
-
+
if returncode == 0:
logger.info(f"Successfully cloned {url} to {self.cwd}")
self._check_git_repo() # Refresh git repo status
@@ -192,13 +206,13 @@ def clone_from(self, url: str, branch: Optional[str] = None, depth: Optional[int
except Exception as e:
logger.error(f"Error cloning repository: {e}")
return False
-
+
def clone_from_path(self, path: Union[str, Path]) -> bool:
"""Clone a git repository from a local path.
-
+
Args:
path: Local path to the git repository
-
+
Returns:
bool: True if successful, False otherwise
"""
@@ -213,41 +227,43 @@ def clone_from_path(self, path: Union[str, Path]) -> bool:
except Exception as e:
logger.error(f"Error cloning repository from path: {e}")
return False
-
+
def execute_command(self, command: Union[str, List[str]]) -> Tuple[str, str, int]:
"""Execute a command in the current directory.
-
+
Args:
command: Command to execute (string or list of arguments)
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
return execute_command(command, cwd=str(self.cwd))
-
+
def read_file(self, file_path: Union[str, Path]) -> str:
"""Read the contents of a file.
-
+
Args:
file_path: Path to the file (relative to cwd)
-
+
Returns:
str: File contents
-
+
Raises:
FileNotFoundError: If the file doesn't exist
"""
path = self.cwd / file_path
- return path.read_text(encoding='utf-8')
-
- def write_file(self, file_path: Union[str, Path], content: str, append: bool = False) -> bool:
+ return path.read_text(encoding="utf-8")
+
+ def write_file(
+ self, file_path: Union[str, Path], content: str, append: bool = False
+ ) -> bool:
"""Write content to a file.
-
+
Args:
file_path: Path to the file (relative to cwd)
content: Content to write
append: Whether to append to the file (default: False)
-
+
Returns:
bool: True if successful, False otherwise
"""
@@ -255,25 +271,29 @@ def write_file(self, file_path: Union[str, Path], content: str, append: bool = F
path = self.cwd / file_path
# Create parent directories if they don't exist
path.parent.mkdir(parents=True, exist_ok=True)
- mode = 'a' if append else 'w'
- with open(path, mode, encoding='utf-8') as f:
+ mode = "a" if append else "w"
+ with open(path, mode, encoding="utf-8") as f:
f.write(content)
return True
except Exception as e:
logger.error(f"Error writing to file {file_path}: {e}")
return False
-
- def edit_file(self, file_path: Union[str, Path],
- find_text: str, replace_text: str,
- use_regex: bool = False) -> bool:
+
+ def edit_file(
+ self,
+ file_path: Union[str, Path],
+ find_text: str,
+ replace_text: str,
+ use_regex: bool = False,
+ ) -> bool:
"""Edit a file by replacing text.
-
+
Args:
file_path: Path to the file (relative to cwd)
find_text: Text to find
replace_text: Text to replace with
use_regex: Whether to use regex for find/replace
-
+
Returns:
bool: True if successful, False otherwise
"""
@@ -282,26 +302,26 @@ def edit_file(self, file_path: Union[str, Path],
if not path.exists():
logger.error(f"File {file_path} does not exist")
return False
-
- content = path.read_text(encoding='utf-8')
-
+
+ content = path.read_text(encoding="utf-8")
+
if use_regex:
new_content = re.sub(find_text, replace_text, content)
else:
new_content = content.replace(find_text, replace_text)
-
- path.write_text(new_content, encoding='utf-8')
+
+ path.write_text(new_content, encoding="utf-8")
return True
except Exception as e:
logger.error(f"Error editing file {file_path}: {e}")
return False
-
+
def list_files(self, pattern: Optional[str] = None) -> List[Path]:
"""List files in the current directory, optionally filtered by pattern.
-
+
Args:
pattern: Glob pattern to filter files
-
+
Returns:
List of Path objects
"""
@@ -309,13 +329,13 @@ def list_files(self, pattern: Optional[str] = None) -> List[Path]:
return list(self.cwd.glob(pattern))
else:
return [p for p in self.cwd.iterdir() if p.is_file()]
-
+
def list_dirs(self, pattern: Optional[str] = None) -> List[Path]:
"""List subdirectories in the current directory, optionally filtered by pattern.
-
+
Args:
pattern: Glob pattern to filter directories
-
+
Returns:
List of Path objects
"""
@@ -323,11 +343,11 @@ def list_dirs(self, pattern: Optional[str] = None) -> List[Path]:
return [p for p in self.cwd.glob(pattern) if p.is_dir()]
else:
return [p for p in self.cwd.iterdir() if p.is_dir()]
-
+
# Git operations
def git_status(self) -> Dict[str, Any]:
"""Get git status information.
-
+
Returns:
Dict containing status information or error message
"""
@@ -338,7 +358,9 @@ def git_status(self) -> Dict[str, Any]:
"branch": self._git_repo.active_branch.name,
"is_dirty": self._git_repo.is_dirty(),
"untracked_files": self._git_repo.untracked_files,
- "modified_files": [item.a_path for item in self._git_repo.index.diff(None)]
+ "modified_files": [
+ item.a_path for item in self._git_repo.index.diff(None)
+ ],
}
except Exception as e:
return {"error": str(e)}
@@ -354,10 +376,10 @@ def git_init(self) -> bool:
def git_add(self, paths: Union[str, List[str], None] = None) -> bool:
"""Add files to git staging area.
-
+
Args:
paths: File path(s) to add, or None to add all
-
+
Returns:
bool: True if successful, False otherwise
"""
@@ -379,41 +401,41 @@ def git_add(self, paths: Union[str, List[str], None] = None) -> bool:
except Exception as e:
logger.error(f"Error adding files to git: {e}")
return False
-
+
def git_commit(self, message: str) -> bool:
"""Commit changes to git repository.
-
+
Args:
message: Commit message
-
+
Returns:
bool: True if successful, False otherwise
"""
if not self.is_gitrepo:
logger.warning("Not a git repository")
return False
-
+
try:
self._git_repo.git.commit(m=message)
return True
except Exception as e:
logger.error(f"Error committing changes: {e}")
return False
-
+
def git_pull(self, remote: str = "origin", branch: Optional[str] = None) -> bool:
"""Pull changes from remote repository.
-
+
Args:
remote: Remote name
branch: Branch name (optional)
-
+
Returns:
bool: True if successful, False otherwise
"""
if not self.is_gitrepo:
logger.warning("Not a git repository")
return False
-
+
try:
if branch:
self._git_repo.git.pull(remote, branch)
@@ -423,21 +445,21 @@ def git_pull(self, remote: str = "origin", branch: Optional[str] = None) -> bool
except Exception as e:
logger.error(f"Error pulling changes: {e}")
return False
-
+
def git_push(self, remote: str = "origin", branch: Optional[str] = None) -> bool:
"""Push changes to remote repository.
-
+
Args:
remote: Remote name
branch: Branch name (optional)
-
+
Returns:
bool: True if successful, False otherwise
"""
if not self.is_gitrepo:
logger.warning("Not a git repository")
return False
-
+
try:
if branch:
self._git_repo.git.push(remote, branch)
@@ -448,52 +470,53 @@ def git_push(self, remote: str = "origin", branch: Optional[str] = None) -> bool
logger.error(f"Error pushing changes: {e}")
return False
+
class LeanRepo(RepoManager):
"""Class for managing Lean repositories with lake support."""
-
- def __init__(self, cwd: Union[str, Path]=None):
+
+ def __init__(self, cwd: Union[str, Path] = None):
"""Initialize LeanRepo with working directory.
-
+
Args:
cwd: Working directory path
"""
super().__init__(cwd)
- self.elan_home = Path(os.environ.get('ELAN_HOME', Path.home() / '.elan'))
- self.elan_bin_dir = self.elan_home / 'bin'
+ self.elan_home = Path(os.environ.get("ELAN_HOME", Path.home() / ".elan"))
+ self.elan_bin_dir = self.elan_home / "bin"
self._lake_exe = None
self.lean_version = self.get_lean_toolchain()
-
+
@property
def lake_exe(self):
if self._lake_exe is None:
self._lake_exe = self.get_lake_executable()
return self._lake_exe
-
+
def get_lake_executable(self) -> Optional[Path]:
"""Get lake executable file path"""
- lake_exe = 'lake.exe' if OS_TYPE == 'Windows' else 'lake'
+ lake_exe = "lake.exe" if OS_TYPE == "Windows" else "lake"
lake_path = self.elan_bin_dir / lake_exe
-
+
if lake_path.exists() and lake_path.is_file():
return lake_path
-
+
# Try to find in PATH
- lake_in_path = shutil.which('lake')
+ lake_in_path = shutil.which("lake")
if lake_in_path:
return Path(lake_in_path)
-
+
return None
def get_lean_toolchain(self) -> Optional[str]:
"""Read lean-toolchain file to get Lean version.
-
+
Returns:
str: Lean version if found, None otherwise
"""
try:
toolchain_file = self.cwd / "lean-toolchain"
if toolchain_file.exists():
- content = toolchain_file.read_text(encoding='utf-8').strip()
+ content = toolchain_file.read_text(encoding="utf-8").strip()
logger.debug(f"Found Lean toolchain: {content}")
return content
else:
@@ -502,28 +525,28 @@ def get_lean_toolchain(self) -> Optional[str]:
except Exception as e:
logger.error(f"Error reading lean-toolchain: {e}")
return None
-
+
def lake(self, args: List[str]) -> Tuple[str, str, int]:
"""Execute lake command with given arguments.
-
+
Args:
args: List of lake command arguments
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
if isinstance(args, str):
args = [args]
command = [str(self.lake_exe)] + args
- logger.debug("Executing lake command: " + ' '.join(command))
+ logger.debug("Executing lake command: " + " ".join(command))
return self.execute_command(command)
-
+
def lake_env_which(self, name: str) -> Tuple[str, str, int]:
"""Check if a lake package is installed.
-
+
Args:
name: Package name
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
@@ -533,13 +556,15 @@ def lake_env_which(self, name: str) -> Tuple[str, str, int]:
else:
logger.error(f"Error checking package {name}: {err}")
return msg, err, code
-
- def lake_init(self,
- name: Optional[str] = None,
- template: Optional[str] = None,
- language: Optional[str] = None) -> Tuple[str, str, int]:
+
+ def lake_init(
+ self,
+ name: Optional[str] = None,
+ template: Optional[str] = None,
+ language: Optional[str] = None,
+ ) -> Tuple[str, str, int]:
"""Initialize lake repository.
-
+
Args:
name: Repository name
template: Template name
@@ -551,7 +576,7 @@ def lake_init(self,
exe executable only
lib library only
math library only with a mathlib dependency
-
+
Templates can be suffixed with `.lean` or `.toml` to produce a Lean or TOML
version of the configuration file, respectively. The default is Lean.
@@ -564,23 +589,27 @@ def lake_init(self,
if name:
cmds.append(name)
if template:
- assert name is not None, "Repository name is required when template is specified"
+ assert name is not None, (
+ "Repository name is required when template is specified"
+ )
assert template in ["std", "exe", "lib", "math"], "Invalid template name"
cmds.append(template)
if language:
- assert language in ["lean", "toml", '.lean', '.toml'], "Invalid language name"
- if not language.startswith('.'):
- language = '.' + language
+ assert language in ["lean", "toml", ".lean", ".toml"], (
+ "Invalid language name"
+ )
+ if not language.startswith("."):
+ language = "." + language
if template is not None:
cmds[-1] += f".{language}"
return self.lake(cmds)
-
+
def lake_build(self, target: Optional[str] = None) -> Tuple[str, str, int]:
"""Build the Lean project using lake.
-
+
Args:
target: Optional build target
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
@@ -588,27 +617,28 @@ def lake_build(self, target: Optional[str] = None) -> Tuple[str, str, int]:
if target:
args.append(target)
return self.lake(args)
-
+
def lake_update(self) -> Tuple[str, str, int]:
"""Update dependencies using lake.
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
return self.lake(["update"])
-
+
def lake_env_lean(
- self,
- filepath: Union[str, Path],
- json: bool = True,
+ self,
+ filepath: Union[str, Path],
+ json: bool = True,
options: Optional[Dict[str, Any]] = None,
- nproc: Optional[int] = None) -> Tuple[str, str, int]:
+ nproc: Optional[int] = None,
+ ) -> Tuple[str, str, int]:
"""Run lean file with lake environment.
-
+
Args:
filepath: Path to the Lean file
json: Whether to return JSON output, default is True
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
@@ -616,48 +646,48 @@ def lake_env_lean(
if json:
args.append("--json")
if options is not None:
- opts = ["-D {}={}".format(k,v) for k,v in options.items()]
+ opts = ["-D {}={}".format(k, v) for k, v in options.items()]
args += opts
if isinstance(nproc, int) and nproc > 0:
# nproc += 1
args += ["-j", str(nproc)]
args.append(str(filepath))
return self.lake(args)
-
+
def lake_clean(self) -> Tuple[str, str, int]:
"""Clean build artifacts using lake.
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
return self.lake(["clean"])
-
+
def lake_test(self) -> Tuple[str, str, int]:
"""Run tests using lake.
-
+
Returns:
Tuple containing stdout, stderr, and return code
"""
return self.lake(["test"])
-
+
def get_project_info(self) -> Dict[str, Any]:
"""Get comprehensive project information.
-
+
Returns:
Dict containing project information
"""
info = {
- 'lean_version': self.get_lean_toolchain(),
- 'has_lakefile_toml': (self.cwd / "lakefile.toml").exists(),
- 'has_lakefile_lean': (self.cwd / "lakefile.lean").exists(),
- 'has_lake_manifest': (self.cwd / "lake-manifest.json").exists(),
- 'build_dir_exists': (self.cwd / ".lake").exists(),
+ "lean_version": self.get_lean_toolchain(),
+ "has_lakefile_toml": (self.cwd / "lakefile.toml").exists(),
+ "has_lakefile_lean": (self.cwd / "lakefile.lean").exists(),
+ "has_lake_manifest": (self.cwd / "lake-manifest.json").exists(),
+ "build_dir_exists": (self.cwd / ".lake").exists(),
}
return info
-
+
def install(self, config: InstallConfig, **kwargs):
"""Install Lean repository.
-
+
Args:
config: InstallConfig object
kwargs: Additional keyword arguments for lake_init
@@ -676,7 +706,7 @@ def install(self, config: InstallConfig, **kwargs):
success = repo.clone_from(
url=config.url,
branch=config.branch,
- depth=1 # Shallow clone for faster download
+ depth=1, # Shallow clone for faster download
)
if not success:
logger.error(f"Failed to clone repository: {config.url}")
diff --git a/leanup/utils/config.py b/leanup/utils/config.py
index 4aebd74..158ec4b 100644
--- a/leanup/utils/config.py
+++ b/leanup/utils/config.py
@@ -2,16 +2,17 @@
from pathlib import Path
from typing import Dict, Any, Optional
from leanup.utils.custom_logger import setup_logger
-from leanup.const import LEANUP_CACHE_DIR, LEANUP_CONFIG_DIR
+from leanup.const import LEANUP_CONFIG_DIR
logger = setup_logger("config_manager")
+
class ConfigManager:
"""Manage leanup configuration"""
-
+
def __init__(self, config_dir: Optional[Path] = None):
"""Initialize config manager
-
+
Args:
config_dir: Custom config directory (default: LEANUP_CONFIG_DIR)
"""
@@ -19,62 +20,63 @@ def __init__(self, config_dir: Optional[Path] = None):
self.config_dir = Path(config_dir)
else:
self.config_dir = LEANUP_CONFIG_DIR
-
+
# Ensure config directory exists
self.config_dir.mkdir(parents=True, exist_ok=True)
-
+
self.config_path = self.config_dir / "config.yaml"
self._config = None
-
+
def config_exists(self) -> bool:
"""Check if config file exists"""
return self.config_path.exists()
-
+
def init_config(self) -> bool:
"""Initialize config file with default settings"""
self.config_dir.mkdir(parents=True, exist_ok=True)
- # Empty toml file
- with open(self.config_path, 'w', encoding='utf-8') as f:
- f.write('')
-
+ with open(self.config_path, "w", encoding="utf-8") as f:
+ f.write("")
+ self._config = {}
+ return True
+
def save_config(self, config: Dict[str, Any]) -> bool:
"""Save config to file"""
try:
- with open(self.config_path, 'w', encoding='utf-8') as f:
+ with open(self.config_path, "w", encoding="utf-8") as f:
yaml.dump(config, f, default_flow_style=False)
self._config = config
return True
except Exception as e:
logger.error(f"Failed to save config: {e}")
return False
-
+
def load_config(self) -> Dict[str, Any]:
"""Load config from file"""
if self._config is not None:
return self._config
-
+
if not self.config_exists():
logger.warning("Config file not found, using defaults")
return {}
-
+
try:
- with open(self.config_path, 'r', encoding='utf-8') as f:
+ with open(self.config_path, "r", encoding="utf-8") as f:
self._config = yaml.safe_load(f) or {}
return self._config
except Exception as e:
logger.error(f"Failed to load config: {e}")
return {}
-
+
def get(self, key: str, default: Any = None) -> Any:
"""Get config value by key (supports dot notation)"""
config = self.load_config()
- keys = key.split('.')
-
+ keys = key.split(".")
+
value = config
for k in keys:
if isinstance(value, dict) and k in value:
value = value[k]
else:
return default
-
+
return value
diff --git a/mkdocs.yml b/mkdocs.yml
index bc76441..1d05512 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -1,5 +1,5 @@
-site_name: LeanUp Documentation
-site_description: Python package for Lean Environment Management
+site_name: LeanUp 文档
+site_description: 用于管理 Lean 数学证明语言环境的 Python 工具
site_author: Lean-zh Community
site_url: https://www.leanprover.cn/LeanUp/
@@ -36,30 +36,10 @@ theme:
- content.code.annotate
- content.action.edit
- content.action.view
- language: en
+ language: zh
plugins:
- search
- - i18n:
- docs_structure: folder
- fallback_to_default: true
- reconfigure_material: true
- reconfigure_search: true
- languages:
- - locale: en
- default: true
- name: English
- build: true
- site_name: "LeanUp Documentation"
- - locale: zh
- name: 中文
- build: true
- site_name: "LeanUp 文档"
- nav_translations:
- Home: 首页
- Getting Started: 开始使用
- Quick Start: 快速开始
- API Reference: API 文档
markdown_extensions:
- pymdownx.highlight:
@@ -84,18 +64,11 @@ markdown_extensions:
custom_checkbox: true
extra:
- alternate:
- - name: English
- link: /LeanUp/
- lang: en
- - name: 中文
- link: /LeanUp/zh/
- lang: zh
social:
- icon: fontawesome/brands/github
link: https://github.com/Lean-zh/LeanUp
nav:
- - Home: index.md
- - Getting Started:
- - Quick Start: getting-started/quickstart.md
- # - API Reference: api-reference.md
\ No newline at end of file
+ - 首页: index.md
+ - 开始使用:
+ - 快速开始: getting-started/quickstart.md
+ # - API Reference: api-reference.md
diff --git a/tests/test_cli.py b/tests/test_cli.py
index f83d197..25aa50f 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -9,18 +9,18 @@
class TestCLI:
"""Test CLI commands"""
-
+
def setup_method(self):
"""Setup test environment"""
self.runner = CliRunner()
-
+
def test_cli_help(self):
"""Test CLI help command"""
- result = self.runner.invoke(cli, ['--help'])
+ result = self.runner.invoke(cli, ["--help"])
assert result.exit_code == 0
- assert 'LeanUp - Lean project management tool' in result.output
-
- @patch('leanup.cli.ElanManager')
+ assert "LeanUp - Lean project management tool" in result.output
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_init_command(self, mock_elan_manager):
"""Test init command"""
# Mock elan manager
@@ -28,64 +28,64 @@ def test_init_command(self, mock_elan_manager):
mock_elan.is_elan_installed.return_value = False
mock_elan.install_elan.return_value = True
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['init'])
-
+
+ result = self.runner.invoke(cli, ["init"])
+
if result.exit_code == 0:
- '✓ elan installed successfully' in result.output
+ "✓ elan installed successfully" in result.output
# mock_elan.install_elan.assert_called_once()
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_init_command_already_installed(self, mock_elan_manager):
"""Test init command when elan is already installed"""
# Mock elan manager
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['init'])
-
+
+ result = self.runner.invoke(cli, ["init"])
+
assert result.exit_code == 0
- assert '✓ elan is already installed' in result.output
+ assert "✓ elan is already installed" in result.output
mock_elan.install_elan.assert_not_called()
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_install_command_latest(self, mock_elan_manager):
"""Test install command for latest version"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
mock_elan.proxy_elan_command.return_value = 0
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['install'])
-
+
+ result = self.runner.invoke(cli, ["install"])
+
assert result.exit_code == 0
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_install_command_specific_version(self, mock_elan_manager):
"""Test install command for specific version"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
mock_elan.proxy_elan_command.return_value = 0
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['install', 'v4.10.0'])
-
+
+ result = self.runner.invoke(cli, ["install", "v4.10.0"])
+
assert result.exit_code == 0
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_install_command_with_force(self, mock_elan_manager):
"""Test install command with force flag"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
mock_elan.proxy_elan_command.return_value = 0
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['install', 'v4.10.0'])
-
+
+ result = self.runner.invoke(cli, ["install", "v4.10.0"])
+
assert result.exit_code == 0
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_install_command_elan_not_installed(self, mock_elan_manager):
"""Test install command when elan is not installed"""
mock_elan = Mock()
@@ -93,64 +93,66 @@ def test_install_command_elan_not_installed(self, mock_elan_manager):
mock_elan.install_elan.return_value = True
mock_elan.proxy_elan_command.return_value = 0
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['install'])
-
+
+ result = self.runner.invoke(cli, ["install"])
+
assert result.exit_code == 0
- assert '✓ elan installed successfully' in result.output
+ assert "✓ elan installed successfully" in result.output
# assert 'Installing latest Lean toolchain...' in result.output
# mock_elan.install_elan.assert_called_once()
# mock_elan.proxy_elan_command.assert_called_once_with(['toolchain', 'install', 'stable'])
-
- @patch('leanup.cli.ElanManager')
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_status_command(self, mock_elan_manager):
"""Test status command"""
# Mock elan manager
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
- mock_elan.get_elan_version.return_value = '4.0.0'
- mock_elan.get_installed_toolchains.return_value = ['leanprover/lean4:v4.10.0', 'leanprover/lean4:v4.9.0']
+ mock_elan.get_elan_version.return_value = "4.0.0"
+ mock_elan.get_installed_toolchains.return_value = [
+ "leanprover/lean4:v4.10.0",
+ "leanprover/lean4:v4.9.0",
+ ]
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['status'])
-
+
+ result = self.runner.invoke(cli, ["status"])
+
assert result.exit_code == 0
- assert '=== LeanUp Status ===' in result.output
- assert 'elan ' in result.output
-
- @patch('leanup.cli.ElanManager')
+ assert "=== LeanUp Status ===" in result.output
+ assert "elan " in result.output
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_status_command_elan_not_installed(self, mock_elan_manager):
"""Test status command when elan is not installed"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = False
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['status'])
-
+
+ result = self.runner.invoke(cli, ["status"])
+
assert result.exit_code == 0
- assert 'elan: ✗ not installed' in result.output
-
- @patch('leanup.cli.ElanManager')
+ assert "elan: ✗ not installed" in result.output
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_elan_proxy_command(self, mock_elan_manager):
"""Test elan proxy command"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = True
mock_elan.proxy_elan_command.return_value = 0
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['elan', 'toolchain', 'list'])
-
- mock_elan.proxy_elan_command.assert_called_once_with(['toolchain', 'list'])
-
- @patch('leanup.cli.ElanManager')
+
+ result = self.runner.invoke(cli, ["elan", "toolchain", "list"])
+
+ mock_elan.proxy_elan_command.assert_called_once_with(["toolchain", "list"])
+
+ @patch("leanup.cli.elan_ops.ElanManager")
def test_elan_proxy_command_not_installed(self, mock_elan_manager):
"""Test elan proxy command when elan is not installed"""
mock_elan = Mock()
mock_elan.is_elan_installed.return_value = False
mock_elan_manager.return_value = mock_elan
-
- result = self.runner.invoke(cli, ['elan', 'toolchain', 'list'])
-
+
+ result = self.runner.invoke(cli, ["elan", "toolchain", "list"])
+
assert result.exit_code == 1
- assert 'elan is not installed. Run \'leanup init\' first.' in result.output
-
+ assert "elan is not installed. Run 'leanup init' first." in result.output
diff --git a/tests/test_repo_cli.py b/tests/test_repo_cli.py
new file mode 100644
index 0000000..143159e
--- /dev/null
+++ b/tests/test_repo_cli.py
@@ -0,0 +1,82 @@
+from pathlib import Path
+import importlib
+
+repo_cli_module = importlib.import_module("leanup.cli.repo")
+
+from click.testing import CliRunner
+
+from leanup.cli import cli
+
+
+def test_repo_install_no_interactive_missing_suffix_fails(monkeypatch):
+ runner = CliRunner()
+ monkeypatch.setattr(
+ repo_cli_module,
+ "resolve_interactive_mode",
+ lambda interactive, auto_prompt_condition: (False, True, False, False, False),
+ )
+
+ result = runner.invoke(cli, ["repo", "install", "-I"])
+
+ assert result.exit_code != 0
+ assert "Missing required repository suffix" in result.output
+
+
+def test_repo_install_interactive_prompts_and_installs(monkeypatch, tmp_path):
+ runner = CliRunner()
+ values = iter(
+ [
+ "leanprover-community/mathlib4",
+ "https://github.com",
+ "v4.14.0",
+ str(tmp_path),
+ "mathlib4_v4140",
+ "REPL,REPL.Main",
+ ]
+ )
+ confirmations = iter([True, False, False])
+ captured = {}
+
+ monkeypatch.setattr(
+ repo_cli_module,
+ "resolve_interactive_mode",
+ lambda interactive, auto_prompt_condition: (None, True, False, True, True),
+ )
+ monkeypatch.setattr(
+ repo_cli_module,
+ "ask_text",
+ lambda message, default="": next(values),
+ )
+ monkeypatch.setattr(
+ repo_cli_module,
+ "ask_confirm",
+ lambda message, default=True: next(confirmations),
+ )
+
+ def fake_install(self):
+ captured.update(
+ {
+ "suffix": self.suffix,
+ "source": self.source,
+ "branch": self.branch,
+ "dest_dir": self.dest_dir,
+ "dest_name": self.dest_name,
+ "lake_update": self.lake_update,
+ "lake_build": self.lake_build,
+ "build_packages": self.build_packages,
+ }
+ )
+
+ monkeypatch.setattr("leanup.repo.manager.InstallConfig.install", fake_install)
+
+ result = runner.invoke(cli, ["repo", "install"])
+
+ assert result.exit_code == 0
+ assert captured["suffix"] == "leanprover-community/mathlib4"
+ assert captured["source"] == "https://github.com"
+ assert captured["branch"] == "v4.14.0"
+ assert captured["dest_dir"] == Path(tmp_path)
+ assert captured["dest_name"] == "mathlib4_v4140"
+ assert captured["lake_update"] is True
+ assert captured["lake_build"] is False
+ assert captured["build_packages"] == ["REPL", "REPL.Main"]
From ed9cec1aa6fea6eb8f2007efee7cb76a30dc8cff Mon Sep 17 00:00:00 2001
From: rexwzh <1073853456@qq.com>
Date: Tue, 7 Apr 2026 07:30:25 +0800
Subject: [PATCH 2/8] refactor: polish leanup interaction prompts
---
leanup/cli/interaction.py | 52 +++++++++++++++++++++++++++++++++++++--
1 file changed, 50 insertions(+), 2 deletions(-)
diff --git a/leanup/cli/interaction.py b/leanup/cli/interaction.py
index 3aca7d4..f9a47a0 100644
--- a/leanup/cli/interaction.py
+++ b/leanup/cli/interaction.py
@@ -61,9 +61,57 @@ def resolve_value(*candidates: Any) -> Any:
return None
+def _get_console():
+ try:
+ from rich.console import Console
+ except ImportError:
+ return None
+ return Console(stderr=True)
+
+
+def _render_heading(title: str, subtitle: str | None = None) -> None:
+ console = _get_console()
+ if not console:
+ if subtitle:
+ click.echo(f"\n{title}\n{subtitle}", err=True)
+ else:
+ click.echo(f"\n{title}", err=True)
+ return
+
+ try:
+ from rich.panel import Panel
+ except ImportError:
+ if subtitle:
+ click.echo(f"\n{title}\n{subtitle}", err=True)
+ else:
+ click.echo(f"\n{title}", err=True)
+ return
+
+ console.print(
+ Panel.fit(
+ subtitle or "",
+ title=f"[bold cyan]{title}[/bold cyan]",
+ border_style="cyan",
+ padding=(0, 1),
+ )
+ )
+
+
+def _render_note(message: str) -> None:
+ console = _get_console()
+ if not console:
+ click.echo(message, err=True)
+ return
+ console.print(f"[dim]{message}[/dim]")
+
+
def ask_text(message: str, default: str = "") -> str:
- return click.prompt(message, default=default, show_default=bool(default), err=True)
+ _render_heading("Input", message)
+ if default:
+ _render_note(f"Press Enter to keep default: {default}")
+ return click.prompt("Value", default=default, show_default=bool(default), err=True)
def ask_confirm(message: str, default: bool = True) -> bool:
- return click.confirm(message, default=default, err=True)
+ _render_heading("Confirm", message)
+ return click.confirm("Continue", default=default, err=True)
From be0e0419e8c5f5d0a3829206a443c3e89d8c4d5f Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Tue, 7 Apr 2026 16:03:55 +0800
Subject: [PATCH 3/8] feat: add setup command for cached Lean project bootstrap
---
README.md | 69 ++++----
docs/getting-started/quickstart.md | 41 +++--
docs/index.md | 5 +-
leanup/__init__.py | 10 +-
leanup/cli/__init__.py | 4 +
leanup/cli/setup.py | 131 +++++++++++++++
leanup/repo/__init__.py | 10 +-
leanup/repo/elan.py | 16 +-
leanup/repo/project_setup.py | 207 ++++++++++++++++++++++++
tests/test_elan_manager.py | 23 ++-
tests/test_setup.py | 248 +++++++++++++++++++++++++++++
11 files changed, 696 insertions(+), 68 deletions(-)
create mode 100644 leanup/cli/setup.py
create mode 100644 leanup/repo/project_setup.py
create mode 100644 tests/test_setup.py
diff --git a/README.md b/README.md
index cc6a3d7..b4107ff 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,7 @@
## 🎯 功能特性
- **📦 仓库管理**: 安装和管理 Lean 仓库,支持命令优先、交互兜底的配置流程
+- **⚡ 项目初始化**: 快速创建固定 Lean 版本的项目,并复用同版本 mathlib 缓存
- **🌍 跨平台支持**: 支持 Linux、macOS 和 Windows
- **📦 简单易用**: 通过 `pip install leanup` 快速安装
- **🔄 命令代理**: 透明代理所有 elan 命令,无缝体验
@@ -45,48 +46,15 @@ pip install -e .
# 查看帮助
leanup --help
-# 安装 elan 并初始化配置
-leanup init
+# 快速初始化一个 Lean + mathlib 项目
+leanup setup ./Demo --lean-version v4.27.0
-# 安装
-leanup install # stable
-
-# 查看状态
-leanup status
-
-# 代理执行 elan 命令
-leanup elan --help
+# 如需手动透传 elan,也可以继续使用
leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
```
## 📖 详细使用指南
-### 管理 Lean 工具链
-
-安装 elan 后,您可以使用 `leanup elan` 命令来管理 Lean 工具链:
-
-```bash
-# 列出所有可用的工具链
-leanup elan toolchain list
-
-# 安装稳定版工具链
-leanup elan toolchain install stable
-
-# 安装夜间构建版本
-leanup elan toolchain install leanprover/lean4:nightly
-
-# 设置默认工具链
-leanup elan default stable
-
-# 更新所有工具链
-leanup elan update
-
-# 查看当前活动的工具链
-leanup elan show
-```
-
### 仓库管理
```bash
@@ -121,6 +89,35 @@ leanup repo list --search-dir /path/to/repos
leanup repo list -n mathlib
```
+### 快速初始化项目
+
+`leanup setup` 用于快速创建一个固定 Lean 版本的项目,并按需要为 `mathlib` 依赖准备共享缓存。
+
+```bash
+# 创建一个带 mathlib 的项目,默认有缓存就复用,没有缓存就构建
+leanup setup ./Demo --lean-version v4.27.0
+
+# 首次为某个版本准备依赖缓存时,从头构建一次
+leanup setup ./DemoBuild --lean-version v4.27.0 --dependency-mode build
+
+# 后续同版本项目可直接软链接复用缓存
+leanup setup ./DemoFast --lean-version v4.27.0 --dependency-mode symlink
+
+# 创建不带 mathlib 的纯 Lean 项目
+leanup setup ./PlainDemo --lean-version v4.27.0 --no-mathlib
+
+# 指定 Lake 项目名,并覆盖已存在目录
+leanup setup ./Demo --lean-version v4.27.0 --name MyDemo --force
+```
+
+规则说明:
+
+- `--dependency-mode symlink` 只在启用 `mathlib` 时可用
+- 默认缓存目录为 `LEANUP_CACHE_DIR/setup/mathlib//packages`
+- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
+- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
+- `setup` 会确保对应 Lean toolchain 已通过 `elan` 安装
+
### 交互式安装
使用 `leanup repo install -i` 时,您可以配置:
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 3aadb80..478af8f 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -14,39 +14,38 @@ pip install -e .
## 基础使用
-### 初始化 elan
+### 查看帮助
```bash
-# 查看帮助
leanup --help
-
-# 安装 elan
-leanup init
-
-# 查看当前状态
-leanup status
```
-### 安装 Lean 工具链
+### 快速创建项目
```bash
-# 安装最新稳定版 Lean 工具链
-leanup install
+# 创建一个带 mathlib 的 Lean 项目,默认有缓存就复用,没有缓存就构建
+leanup setup ./Demo --lean-version v4.27.0
-# 安装特定版本的 Lean 工具链
-leanup install v4.14.0
-```
+# 首次为某个版本准备缓存时,从头构建依赖
+leanup setup ./DemoBuild --lean-version v4.27.0 --dependency-mode build
-### 管理工具链
+# 后续项目直接复用共享依赖缓存
+leanup setup ./DemoFast --lean-version v4.27.0 --dependency-mode symlink
-```bash
-# 代理执行 elan 命令
-leanup elan --help
-leanup elan toolchain list
-leanup elan toolchain install stable
-leanup elan default stable
+# 创建不依赖 mathlib 的纯 Lean 项目
+leanup setup ./PlainDemo --lean-version v4.27.0 --no-mathlib
```
+说明:
+
+- 共享缓存默认放在 `LEANUP_CACHE_DIR/setup/mathlib//packages`
+- Linux 默认通常对应 `~/.cache/leanup/setup/mathlib//packages`
+- `symlink` 模式只对 `mathlib` 项目开放
+- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
+- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
+- `setup` 会自动确保 `elan` 和目标 Lean toolchain 已安装
+- 如果你需要直接透传给 `elan`,仍然可以使用 `leanup elan ...`
+
### 仓库管理
```bash
diff --git a/docs/index.md b/docs/index.md
index a47c53f..2de3f4f 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -4,10 +4,7 @@
## 功能特性
-- `leanup init`:安装并初始化 elan
-- `leanup install [version]`:通过 elan 安装 Lean 工具链
-- `leanup status`:查看当前 elan / toolchain 状态
-- `leanup elan `:透明代理 elan 命令
+- `leanup setup`:快速创建固定 Lean 版本项目,支持 mathlib 共享缓存
- `leanup repo install`:安装 Lean 仓库,支持命令优先、交互补参
- `leanup repo list`:查看已安装仓库
diff --git a/leanup/__init__.py b/leanup/__init__.py
index 2d87293..9f20293 100644
--- a/leanup/__init__.py
+++ b/leanup/__init__.py
@@ -4,10 +4,18 @@
__email__ = 'leanprover@outlook.com'
__version__ = '0.1.9'
-from .repo import RepoManager, ElanManager, LeanRepo
+from .repo import (
+ RepoManager,
+ ElanManager,
+ LeanRepo,
+ LeanProjectSetup,
+ SetupConfig,
+ SetupResult,
+)
from .utils import setup_logger, execute_command, working_directory
__all__ = [
'RepoManager', 'ElanManager', 'LeanRepo',
+ 'LeanProjectSetup', 'SetupConfig', 'SetupResult',
"setup_logger", "execute_command", "working_directory"
]
diff --git a/leanup/cli/__init__.py b/leanup/cli/__init__.py
index 7e8c12e..76a79f1 100644
--- a/leanup/cli/__init__.py
+++ b/leanup/cli/__init__.py
@@ -3,6 +3,7 @@
from leanup.utils.custom_logger import setup_logger
from leanup.cli.repo import repo
+from leanup.cli.setup import setup_project
from leanup.cli.elan_ops import (
init_elan,
install_lean_toolchain,
@@ -40,6 +41,9 @@ def status():
show_status()
+cli.add_command(setup_project)
+
+
@cli.command(context_settings=dict(ignore_unknown_options=True))
@click.argument("args", nargs=-1, type=click.UNPROCESSED)
def elan(args):
diff --git a/leanup/cli/setup.py b/leanup/cli/setup.py
new file mode 100644
index 0000000..cf3a616
--- /dev/null
+++ b/leanup/cli/setup.py
@@ -0,0 +1,131 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import click
+
+from leanup.cli.interaction import (
+ abort_if_force_without_tty,
+ abort_if_missing_without_tty,
+ ask_confirm,
+ ask_text,
+ resolve_interactive_mode,
+)
+from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
+
+
+@click.command(name="setup")
+@click.argument("path", required=False, type=click.Path(path_type=Path))
+@click.option(
+ "--lean-version",
+ "-v",
+ help="Lean version like v4.27.0 or 4.27.0.",
+)
+@click.option("--name", help="Lake project name. Defaults to the target directory name.")
+@click.option(
+ "--mathlib/--no-mathlib",
+ default=True,
+ help="Enable or disable the mathlib dependency.",
+)
+@click.option(
+ "--dependency-mode",
+ "-m",
+ type=click.Choice(["symlink", "build"]),
+ default=None,
+ help="Use cached mathlib packages via symlink, or build dependencies from scratch.",
+)
+@click.option("--force", "-f", is_flag=True, help="Replace the target directory if it exists.")
+@click.option(
+ "--interactive/--no-interactive",
+ "-i/-I",
+ default=None,
+ help="Auto prompt on missing args, -i forces interactive, -I disables it.",
+)
+def setup_project(
+ path: Path | None,
+ lean_version: str | None,
+ name: str | None,
+ mathlib: bool,
+ dependency_mode: str | None,
+ force: bool,
+ interactive: bool | None,
+) -> None:
+ """Create a Lean project with a pinned toolchain and optional mathlib cache reuse."""
+ usage = "Usage: leanup setup [PATH] --lean-version [--name ] [--mathlib/--no-mathlib] [--dependency-mode symlink|build] [--force] [-i|-I]"
+ missing_required = path is None or not lean_version
+ interactive, can_prompt, force_interactive, _, need_prompt = (
+ resolve_interactive_mode(
+ interactive=interactive,
+ auto_prompt_condition=missing_required,
+ )
+ )
+
+ abort_if_force_without_tty(force_interactive, can_prompt, usage)
+ abort_if_missing_without_tty(
+ missing_required=missing_required,
+ interactive=interactive,
+ can_prompt=can_prompt,
+ message="Missing required setup path or Lean version and no TTY is available for interactive prompts.",
+ usage=usage,
+ )
+
+ if missing_required and not need_prompt:
+ raise click.ClickException("Missing required setup path or Lean version.")
+
+ if need_prompt:
+ path_input = ask_text("Project directory", default=str(path) if path else "")
+ path = Path(path_input)
+ lean_version = ask_text("Lean version", default=lean_version or "")
+ name = ask_text("Lake project name", default=name or path.name) or None
+ mathlib = ask_confirm("Enable mathlib dependency?", default=mathlib)
+
+ preview_mode = None
+ try:
+ preview_mode = SetupConfig(
+ target_dir=path,
+ lean_version=lean_version,
+ project_name=name,
+ mathlib=mathlib,
+ dependency_mode=dependency_mode,
+ force=force,
+ ).resolved_dependency_mode
+ except ValueError:
+ preview_mode = dependency_mode
+
+ default_mode = preview_mode or dependency_mode or ("build" if not mathlib else "symlink")
+
+ if mathlib:
+ dependency_mode = ask_text(
+ "Dependency mode (symlink/build)",
+ default=default_mode,
+ ).strip() or default_mode
+ else:
+ dependency_mode = "build"
+
+ force = ask_confirm("Replace the target directory if it exists?", default=force)
+
+ config = SetupConfig(
+ target_dir=path,
+ lean_version=lean_version,
+ project_name=name,
+ mathlib=mathlib,
+ dependency_mode=dependency_mode,
+ force=force,
+ )
+ manager = LeanProjectSetup()
+
+ try:
+ result = manager.setup(config)
+ except (ValueError, RuntimeError) as exc:
+ raise click.ClickException(str(exc)) from exc
+
+ click.echo(f"✓ Project ready: {result.target_dir}")
+ click.echo(f" Lean version: {result.lean_version}")
+ click.echo(f" Mathlib: {'enabled' if result.mathlib else 'disabled'}")
+ click.echo(f" Dependency mode: {result.dependency_mode}")
+ if result.cache_dir:
+ click.echo(f" Cache dir: {result.cache_dir}")
+ if result.used_cache:
+ click.echo(" Reused cached mathlib packages via symlink.")
+ else:
+ click.echo(" Refreshed the shared mathlib package cache for this Lean version.")
diff --git a/leanup/repo/__init__.py b/leanup/repo/__init__.py
index 8db6172..5099f6e 100644
--- a/leanup/repo/__init__.py
+++ b/leanup/repo/__init__.py
@@ -2,5 +2,13 @@
from .manager import RepoManager, LeanRepo
from .elan import ElanManager
+from .project_setup import LeanProjectSetup, SetupConfig, SetupResult
-__all__ = ['RepoManager', 'ElanManager', 'LeanRepo']
+__all__ = [
+ 'RepoManager',
+ 'ElanManager',
+ 'LeanRepo',
+ 'LeanProjectSetup',
+ 'SetupConfig',
+ 'SetupResult',
+]
diff --git a/leanup/repo/elan.py b/leanup/repo/elan.py
index cf034b2..67c4db1 100644
--- a/leanup/repo/elan.py
+++ b/leanup/repo/elan.py
@@ -156,15 +156,23 @@ def install_elan(self, force: bool = False) -> bool:
return False
def install_lean(self, version: str = None) -> bool:
+ requested = version or "stable"
installed = self.get_installed_toolchains()
- if version in installed:
- logger.info(f"Lean toolchain {version} is already installed")
+ if self._is_toolchain_installed(requested, installed):
+ logger.info(f"Lean toolchain {requested} is already installed")
return True
- version = version or "stable"
- cmd = ["toolchain", "install", version]
+ cmd = ["toolchain", "install", requested]
result = self.proxy_elan_command(cmd)
return result == 0
+ def _is_toolchain_installed(self, requested: str, installed: List[str]) -> bool:
+ candidates = {requested}
+ if requested.startswith("v"):
+ candidates.add(f"leanprover/lean4:{requested}")
+ elif requested.startswith("leanprover/lean4:"):
+ candidates.add(requested.split(":", 1)[1])
+ return any(toolchain in candidates for toolchain in installed)
+
def proxy_elan_command(self, args: List[str]) -> int:
"""Proxy execute elan command with streaming output"""
if not self.elan_exe:
diff --git a/leanup/repo/project_setup.py b/leanup/repo/project_setup.py
new file mode 100644
index 0000000..9af1774
--- /dev/null
+++ b/leanup/repo/project_setup.py
@@ -0,0 +1,207 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+import os
+from pathlib import Path
+import re
+import shutil
+
+from leanup.const import LEANUP_CACHE_DIR
+from leanup.repo.elan import ElanManager
+from leanup.repo.manager import LeanRepo
+from leanup.utils.basic import working_directory
+from leanup.utils.custom_logger import setup_logger
+
+logger = setup_logger("project_setup")
+
+LEAN_VERSION_PATTERN = re.compile(r"^v?4\.\d+\.\d+$")
+
+
+def normalize_lean_version(version: str) -> str:
+ normalized = version.strip()
+ if not LEAN_VERSION_PATTERN.match(normalized):
+ raise ValueError("Lean version must look like v4.x.x or 4.x.x.")
+ if not normalized.startswith("v"):
+ normalized = f"v{normalized}"
+ return normalized
+
+
+def sanitize_project_name(name: str) -> str:
+ sanitized = re.sub(r"[^A-Za-z0-9_]", "", name.strip())
+ if not sanitized:
+ sanitized = "LeanProject"
+ if sanitized[0].isdigit():
+ sanitized = f"Lean{sanitized}"
+ return sanitized
+
+
+def remove_path(path: Path) -> None:
+ if not path.exists() and not path.is_symlink():
+ return
+ if path.is_symlink() or path.is_file():
+ path.unlink()
+ return
+ shutil.rmtree(path)
+
+
+@dataclass
+class SetupConfig:
+ target_dir: Path
+ lean_version: str
+ project_name: str | None = None
+ mathlib: bool = True
+ dependency_mode: str | None = None
+ force: bool = False
+
+ def __post_init__(self) -> None:
+ self.target_dir = Path(self.target_dir).expanduser().resolve()
+ self.lean_version = normalize_lean_version(self.lean_version)
+ default_name = self.project_name or self.target_dir.name
+ self.project_name = sanitize_project_name(default_name)
+
+ @property
+ def template(self) -> str:
+ return "math" if self.mathlib else "std"
+
+ @property
+ def resolved_dependency_mode(self) -> str:
+ if self.dependency_mode:
+ return self.dependency_mode
+ if not self.mathlib:
+ return "build"
+ return "symlink" if self.mathlib_cache_dir.exists() else "build"
+
+ @property
+ def toolchain(self) -> str:
+ return f"leanprover/lean4:{self.lean_version}"
+
+ @property
+ def mathlib_cache_dir(self) -> Path:
+ return LEANUP_CACHE_DIR / "setup" / "mathlib" / self.lean_version / "packages"
+
+ def validate(self) -> None:
+ if self.resolved_dependency_mode not in {"symlink", "build"}:
+ raise ValueError("Dependency mode must be either 'symlink' or 'build'.")
+ if self.resolved_dependency_mode == "symlink" and not self.mathlib:
+ raise ValueError("Dependency symlink mode is only available when mathlib is enabled.")
+
+
+@dataclass
+class SetupResult:
+ target_dir: Path
+ lean_version: str
+ mathlib: bool
+ dependency_mode: str
+ cache_dir: Path | None = None
+ used_cache: bool = False
+
+
+class LeanProjectSetup:
+ def __init__(self, elan_manager: ElanManager | None = None):
+ self.elan_manager = elan_manager or ElanManager()
+
+ def setup(self, config: SetupConfig) -> SetupResult:
+ config.validate()
+ self._ensure_target_available(config)
+ self._ensure_toolchain(config.lean_version)
+
+ with working_directory() as temp_dir:
+ temp_root = LeanRepo(temp_dir)
+ stdout, stderr, returncode = temp_root.lake_init(
+ config.project_name,
+ config.template,
+ )
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "Failed to initialize Lean project.")
+
+ project_dir = temp_dir / config.project_name
+ project = LeanRepo(project_dir)
+ self._write_toolchain(project_dir, config.toolchain)
+
+ used_cache = False
+ cache_dir = config.mathlib_cache_dir if config.mathlib else None
+
+ if config.mathlib and config.resolved_dependency_mode == "symlink":
+ self._link_mathlib_cache(config, project_dir)
+ used_cache = True
+
+ if config.mathlib:
+ self._run_lake_update(project)
+
+ self._run_lake_build(project)
+
+ if config.mathlib and config.resolved_dependency_mode == "build":
+ self._refresh_mathlib_cache(config, project_dir)
+
+ shutil.move(str(project_dir), str(config.target_dir))
+
+ return SetupResult(
+ target_dir=config.target_dir,
+ lean_version=config.lean_version,
+ mathlib=config.mathlib,
+ dependency_mode=config.resolved_dependency_mode,
+ cache_dir=cache_dir,
+ used_cache=used_cache,
+ )
+
+ def _ensure_target_available(self, config: SetupConfig) -> None:
+ target = config.target_dir
+ if target.exists() or target.is_symlink():
+ if not config.force:
+ raise ValueError(f"Target directory already exists: {target}")
+ remove_path(target)
+
+ target.parent.mkdir(parents=True, exist_ok=True)
+
+ def _ensure_toolchain(self, version: str) -> None:
+ if not self.elan_manager.is_elan_installed() and not self.elan_manager.install_elan():
+ raise RuntimeError("Failed to install elan.")
+ if not self.elan_manager.install_lean(version):
+ raise RuntimeError(f"Failed to install Lean toolchain {version}.")
+
+ def _write_toolchain(self, project_dir: Path, toolchain: str) -> None:
+ (project_dir / "lean-toolchain").write_text(toolchain + "\n", encoding="utf-8")
+
+ def _run_lake_update(self, repo: LeanRepo) -> None:
+ stdout, stderr, returncode = repo.lake_update()
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "lake update failed.")
+
+ def _run_lake_build(self, repo: LeanRepo) -> None:
+ stdout, stderr, returncode = repo.lake_build()
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "lake build failed.")
+
+ def _link_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None:
+ cache_dir = config.mathlib_cache_dir
+ if not cache_dir.exists():
+ raise ValueError(
+ "No cached mathlib packages found for this Lean version. "
+ "Run setup with --dependency-mode build first."
+ )
+
+ packages_dir = project_dir / ".lake" / "packages"
+ packages_dir.parent.mkdir(parents=True, exist_ok=True)
+ remove_path(packages_dir)
+
+ try:
+ packages_dir.symlink_to(cache_dir, target_is_directory=True)
+ except OSError as exc:
+ raise RuntimeError(f"Failed to create dependency symlink: {exc}") from exc
+
+ def _refresh_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None:
+ source_dir = project_dir / ".lake" / "packages"
+ if not source_dir.exists():
+ logger.warning("Skipping cache refresh because .lake/packages does not exist.")
+ return
+
+ cache_dir = config.mathlib_cache_dir
+ cache_parent = cache_dir.parent
+ cache_parent.mkdir(parents=True, exist_ok=True)
+
+ temp_cache_dir = cache_parent / f".{cache_dir.name}.tmp"
+ remove_path(temp_cache_dir)
+ shutil.copytree(source_dir, temp_cache_dir, symlinks=True)
+
+ remove_path(cache_dir)
+ os.replace(temp_cache_dir, cache_dir)
diff --git a/tests/test_elan_manager.py b/tests/test_elan_manager.py
index 1a89bb6..73e5ffa 100644
--- a/tests/test_elan_manager.py
+++ b/tests/test_elan_manager.py
@@ -103,6 +103,27 @@ def test_download_installer_success(self, mock_get, mock_elan_home, temp_dir):
assert result is True
assert target_path.exists()
+
+ def test_install_lean_skips_when_version_already_installed(self, mock_elan_home):
+ with patch.dict(os.environ, {'ELAN_HOME': str(mock_elan_home)}):
+ manager = ElanManager()
+
+ with patch.object(
+ manager,
+ 'get_installed_toolchains',
+ return_value=['leanprover/lean4:v4.22.0', 'stable'],
+ ), patch.object(manager, 'proxy_elan_command') as mock_proxy:
+ assert manager.install_lean('v4.22.0') is True
+ mock_proxy.assert_not_called()
+
+ def test_install_lean_installs_when_version_missing(self, mock_elan_home):
+ with patch.dict(os.environ, {'ELAN_HOME': str(mock_elan_home)}):
+ manager = ElanManager()
+
+ with patch.object(manager, 'get_installed_toolchains', return_value=[]), \
+ patch.object(manager, 'proxy_elan_command', return_value=0) as mock_proxy:
+ assert manager.install_lean('v4.22.0') is True
+ mock_proxy.assert_called_once_with(['toolchain', 'install', 'v4.22.0'])
@patch('subprocess.run')
@pytest.mark.skipif(OS_TYPE == 'Windows', reason="Windows path separator issue")
@@ -145,4 +166,4 @@ def test_get_status_info_not_installed(self, mock_elan_home):
assert info['installed'] is False
assert info['version'] is None
assert info['executable'] is None
- assert info['toolchains'] == []
\ No newline at end of file
+ assert info['toolchains'] == []
diff --git a/tests/test_setup.py b/tests/test_setup.py
new file mode 100644
index 0000000..44f1d9e
--- /dev/null
+++ b/tests/test_setup.py
@@ -0,0 +1,248 @@
+from pathlib import Path
+
+from click.testing import CliRunner
+
+from leanup.cli import cli
+from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
+
+
+def test_setup_command_rejects_symlink_without_mathlib():
+ runner = CliRunner()
+
+ result = runner.invoke(
+ cli,
+ [
+ "setup",
+ "Demo",
+ "--lean-version",
+ "v4.27.0",
+ "--no-mathlib",
+ "--dependency-mode",
+ "symlink",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "only available when mathlib is enabled" in result.output
+
+
+def test_setup_command_uses_expected_defaults(monkeypatch, tmp_path):
+ runner = CliRunner()
+ captured = {}
+
+ def fake_setup(self, config):
+ captured["target_dir"] = config.target_dir
+ captured["lean_version"] = config.lean_version
+ captured["mathlib"] = config.mathlib
+ captured["dependency_mode"] = config.resolved_dependency_mode
+ return type(
+ "Result",
+ (),
+ {
+ "target_dir": config.target_dir,
+ "lean_version": config.lean_version,
+ "mathlib": config.mathlib,
+ "dependency_mode": config.resolved_dependency_mode,
+ "cache_dir": config.mathlib_cache_dir,
+ "used_cache": True,
+ },
+ )()
+
+ monkeypatch.setattr(LeanProjectSetup, "setup", fake_setup)
+
+ target = tmp_path / "Demo"
+ result = runner.invoke(cli, ["setup", str(target), "--lean-version", "4.27.0"])
+
+ assert result.exit_code == 0
+ assert captured["target_dir"] == target.resolve()
+ assert captured["lean_version"] == "v4.27.0"
+ assert captured["mathlib"] is True
+ assert captured["dependency_mode"] == "build"
+
+
+def test_setup_interactive_uses_previewed_dependency_mode(monkeypatch, tmp_path):
+ import importlib
+
+ setup_cli_module = importlib.import_module("leanup.cli.setup")
+ runner = CliRunner()
+ captured = {}
+
+ def fake_setup(self, config):
+ captured["dependency_mode"] = config.resolved_dependency_mode
+ return type(
+ "Result",
+ (),
+ {
+ "target_dir": config.target_dir,
+ "lean_version": config.lean_version,
+ "mathlib": config.mathlib,
+ "dependency_mode": config.resolved_dependency_mode,
+ "cache_dir": config.mathlib_cache_dir,
+ "used_cache": False,
+ },
+ )()
+
+ monkeypatch.setattr(
+ setup_cli_module,
+ "resolve_interactive_mode",
+ lambda interactive, auto_prompt_condition: (None, True, False, True, True),
+ )
+ values = iter([
+ str(tmp_path / "Demo"),
+ "v4.27.0",
+ "Demo",
+ "build",
+ ])
+ confirmations = iter([True, False])
+ monkeypatch.setattr(setup_cli_module, "ask_text", lambda message, default="": next(values))
+ monkeypatch.setattr(setup_cli_module, "ask_confirm", lambda message, default=True: next(confirmations))
+ monkeypatch.setattr(LeanProjectSetup, "setup", fake_setup)
+
+ result = runner.invoke(cli, ["setup"])
+
+ assert result.exit_code == 0
+ assert captured["dependency_mode"] == "build"
+
+
+def test_setup_config_prefers_symlink_when_cache_exists(tmp_path):
+ from leanup.repo import project_setup as project_setup_module
+
+ original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
+ project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+
+ try:
+ config = SetupConfig(target_dir=tmp_path / "Demo", lean_version="v4.27.0")
+ assert config.resolved_dependency_mode == "build"
+
+ config.mathlib_cache_dir.mkdir(parents=True, exist_ok=True)
+ assert config.resolved_dependency_mode == "symlink"
+ finally:
+ project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+
+
+def test_setup_build_mode_populates_shared_cache(tmp_path):
+ class FakeElanManager:
+ def is_elan_installed(self):
+ return True
+
+ def install_elan(self):
+ return True
+
+ def install_lean(self, version):
+ return True
+
+ def fake_lake_init(self, name, template=None):
+ project_dir = self.cwd / name
+ project_dir.mkdir(parents=True)
+ (project_dir / "lakefile.lean").write_text(f"template={template}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_update(self):
+ packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached mathlib\n", encoding="utf-8")
+ (self.cwd / "lake-manifest.json").write_text("{}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_build(self):
+ build_dir = self.cwd / ".lake" / "build"
+ build_dir.mkdir(parents=True, exist_ok=True)
+ return "", "", 0
+
+ from leanup.repo import project_setup as project_setup_module
+
+ original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
+ project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+
+ try:
+ from leanup.repo.manager import LeanRepo
+
+ original_lake_init = LeanRepo.lake_init
+ original_lake_update = LeanRepo.lake_update
+ original_lake_build = LeanRepo.lake_build
+ LeanRepo.lake_init = fake_lake_init
+ LeanRepo.lake_update = fake_lake_update
+ LeanRepo.lake_build = fake_lake_build
+
+ manager = LeanProjectSetup(elan_manager=FakeElanManager())
+ target = tmp_path / "BuildDemo"
+ config = SetupConfig(
+ target_dir=target,
+ lean_version="v4.27.0",
+ dependency_mode="build",
+ )
+
+ result = manager.setup(config)
+
+ assert result.used_cache is False
+ assert (target / "lean-toolchain").read_text(encoding="utf-8").strip() == "leanprover/lean4:v4.27.0"
+ assert config.mathlib_cache_dir.exists()
+ assert (config.mathlib_cache_dir / "mathlib" / "README.md").exists()
+ finally:
+ project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+ LeanRepo.lake_init = original_lake_init
+ LeanRepo.lake_update = original_lake_update
+ LeanRepo.lake_build = original_lake_build
+
+
+def test_setup_symlink_mode_reuses_shared_cache(tmp_path):
+ class FakeElanManager:
+ def is_elan_installed(self):
+ return True
+
+ def install_elan(self):
+ return True
+
+ def install_lean(self, version):
+ return True
+
+ def fake_lake_init(self, name, template=None):
+ project_dir = self.cwd / name
+ project_dir.mkdir(parents=True)
+ (project_dir / "lakefile.lean").write_text(f"template={template}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_update(self):
+ packages_dir = self.cwd / ".lake" / "packages"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (self.cwd / "lake-manifest.json").write_text("{}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_build(self):
+ return "", "", 0
+
+ from leanup.repo import project_setup as project_setup_module
+
+ original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
+ project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+
+ try:
+ from leanup.repo.manager import LeanRepo
+
+ original_lake_init = LeanRepo.lake_init
+ original_lake_update = LeanRepo.lake_update
+ original_lake_build = LeanRepo.lake_build
+ LeanRepo.lake_init = fake_lake_init
+ LeanRepo.lake_update = fake_lake_update
+ LeanRepo.lake_build = fake_lake_build
+
+ cached_packages = project_setup_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.27.0" / "packages" / "mathlib"
+ cached_packages.mkdir(parents=True, exist_ok=True)
+ (cached_packages / "README.md").write_text("cached\n", encoding="utf-8")
+
+ manager = LeanProjectSetup(elan_manager=FakeElanManager())
+ target = tmp_path / "SymlinkDemo"
+ config = SetupConfig(target_dir=target, lean_version="v4.27.0", dependency_mode="symlink")
+
+ result = manager.setup(config)
+
+ packages_link = target / ".lake" / "packages"
+ assert result.used_cache is True
+ assert packages_link.is_symlink()
+ assert packages_link.resolve() == config.mathlib_cache_dir
+ assert (packages_link / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cached"
+ finally:
+ project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+ LeanRepo.lake_init = original_lake_init
+ LeanRepo.lake_update = original_lake_update
+ LeanRepo.lake_build = original_lake_build
From 6b2d4b428826fe72771b9310c7377f3b2737f76c Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Tue, 7 Apr 2026 16:10:46 +0800
Subject: [PATCH 4/8] feat: add reusable mathlib cache commands
---
README.md | 22 +++++
docs/getting-started/quickstart.md | 16 +++
docs/index.md | 1 +
leanup/__init__.py | 3 +-
leanup/cli/__init__.py | 2 +
leanup/cli/mathlib.py | 100 +++++++++++++++++++
leanup/repo/__init__.py | 2 +
leanup/repo/mathlib_cache.py | 153 +++++++++++++++++++++++++++++
leanup/repo/project_setup.py | 33 ++-----
tests/test_mathlib_cache.py | 145 +++++++++++++++++++++++++++
tests/test_setup.py | 29 +++---
11 files changed, 465 insertions(+), 41 deletions(-)
create mode 100644 leanup/cli/mathlib.py
create mode 100644 leanup/repo/mathlib_cache.py
create mode 100644 tests/test_mathlib_cache.py
diff --git a/README.md b/README.md
index b4107ff..4b551cf 100644
--- a/README.md
+++ b/README.md
@@ -114,10 +114,32 @@ leanup setup ./Demo --lean-version v4.27.0 --name MyDemo --force
- `--dependency-mode symlink` 只在启用 `mathlib` 时可用
- 默认缓存目录为 `LEANUP_CACHE_DIR/setup/mathlib//packages`
+- 在当前 workspace 中,默认也会尝试从 `reference/Projects/cache//packages.tar.gz` 自动导入缓存
- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
- `setup` 会确保对应 Lean toolchain 已通过 `elan` 安装
+### 管理 mathlib 缓存
+
+```bash
+# 查看 LeanUp 已有缓存,以及 reference 中可导入的缓存
+leanup mathlib cache list
+
+# 只看本地已导入缓存
+leanup mathlib cache list --local-only
+
+# 只看可从 reference 导入的缓存
+leanup mathlib cache list --importable-only
+
+# 导入某个版本的 reference cache 到 LeanUp 默认缓存目录
+leanup mathlib cache import v4.22.0
+
+# 一次性导入全部 reference cache
+leanup mathlib cache import --all
+```
+
+如果需要覆盖 reference cache 来源目录,可以设置环境变量 `LEANUP_MATHLIB_CACHE_SOURCE`,或者在命令里传 `--source-dir`。
+
### 交互式安装
使用 `leanup repo install -i` 时,您可以配置:
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 478af8f..d4b0266 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -40,12 +40,28 @@ leanup setup ./PlainDemo --lean-version v4.27.0 --no-mathlib
- 共享缓存默认放在 `LEANUP_CACHE_DIR/setup/mathlib//packages`
- Linux 默认通常对应 `~/.cache/leanup/setup/mathlib//packages`
+- 在当前 workspace 中,默认也会尝试从 `reference/Projects/cache//packages.tar.gz` 自动导入缓存
- `symlink` 模式只对 `mathlib` 项目开放
- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
- `setup` 会自动确保 `elan` 和目标 Lean toolchain 已安装
- 如果你需要直接透传给 `elan`,仍然可以使用 `leanup elan ...`
+### 管理 mathlib 缓存
+
+```bash
+# 查看 LeanUp 已有缓存,以及 reference 中可导入的缓存
+leanup mathlib cache list
+
+# 导入某个版本的 reference cache
+leanup mathlib cache import v4.22.0
+
+# 导入全部 reference cache
+leanup mathlib cache import --all
+```
+
+如果需要覆盖 reference cache 来源目录,可以设置 `LEANUP_MATHLIB_CACHE_SOURCE`,或者传 `--source-dir`。
+
### 仓库管理
```bash
diff --git a/docs/index.md b/docs/index.md
index 2de3f4f..dc64650 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,6 +5,7 @@
## 功能特性
- `leanup setup`:快速创建固定 Lean 版本项目,支持 mathlib 共享缓存
+- `leanup mathlib cache ...`:查看和导入 mathlib 共享缓存
- `leanup repo install`:安装 Lean 仓库,支持命令优先、交互补参
- `leanup repo list`:查看已安装仓库
diff --git a/leanup/__init__.py b/leanup/__init__.py
index 9f20293..c88e01c 100644
--- a/leanup/__init__.py
+++ b/leanup/__init__.py
@@ -7,6 +7,7 @@
from .repo import (
RepoManager,
ElanManager,
+ MathlibCacheManager,
LeanRepo,
LeanProjectSetup,
SetupConfig,
@@ -15,7 +16,7 @@
from .utils import setup_logger, execute_command, working_directory
__all__ = [
- 'RepoManager', 'ElanManager', 'LeanRepo',
+ 'RepoManager', 'ElanManager', 'MathlibCacheManager', 'LeanRepo',
'LeanProjectSetup', 'SetupConfig', 'SetupResult',
"setup_logger", "execute_command", "working_directory"
]
diff --git a/leanup/cli/__init__.py b/leanup/cli/__init__.py
index 76a79f1..568d605 100644
--- a/leanup/cli/__init__.py
+++ b/leanup/cli/__init__.py
@@ -2,6 +2,7 @@
import sys
from leanup.utils.custom_logger import setup_logger
+from leanup.cli.mathlib import mathlib
from leanup.cli.repo import repo
from leanup.cli.setup import setup_project
from leanup.cli.elan_ops import (
@@ -42,6 +43,7 @@ def status():
cli.add_command(setup_project)
+cli.add_command(mathlib)
@cli.command(context_settings=dict(ignore_unknown_options=True))
diff --git a/leanup/cli/mathlib.py b/leanup/cli/mathlib.py
new file mode 100644
index 0000000..1add611
--- /dev/null
+++ b/leanup/cli/mathlib.py
@@ -0,0 +1,100 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+import click
+
+from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version
+
+
+@click.group()
+def mathlib() -> None:
+ """Manage mathlib-specific helpers and caches."""
+
+
+@mathlib.group()
+def cache() -> None:
+ """Manage reusable mathlib package caches."""
+
+
+@cache.command(name="list")
+@click.option(
+ "--local-only",
+ is_flag=True,
+ help="Show only caches already imported into LeanUp's cache directory.",
+)
+@click.option(
+ "--importable-only",
+ is_flag=True,
+ help="Show only versions that can be imported from the reference cache source.",
+)
+@click.option(
+ "--source-dir",
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
+ help="Override the reference cache directory to scan for packages.tar.gz archives.",
+)
+def list_cache(local_only: bool, importable_only: bool, source_dir: Path | None) -> None:
+ """List available mathlib caches."""
+ manager = MathlibCacheManager()
+ entries = manager.list_entries(source_dir=source_dir)
+
+ filtered = []
+ for entry in entries:
+ if local_only and not entry.local_available:
+ continue
+ if importable_only and not entry.importable:
+ continue
+ filtered.append(entry)
+
+ if not filtered:
+ click.echo("No mathlib caches found.")
+ return
+
+ for entry in filtered:
+ status = []
+ if entry.local_available:
+ status.append("local")
+ if entry.importable:
+ status.append("importable")
+ click.echo(f"{entry.version}\t{', '.join(status) or 'unavailable'}")
+ click.echo(f" local: {entry.local_path}")
+ if entry.archive_path:
+ click.echo(f" archive: {entry.archive_path}")
+
+
+@cache.command(name="import")
+@click.argument("version", required=False)
+@click.option("--all", "import_all", is_flag=True, help="Import all importable cache archives.")
+@click.option("--force", is_flag=True, help="Replace an existing imported cache.")
+@click.option(
+ "--source-dir",
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
+ help="Override the reference cache directory containing versioned packages.tar.gz archives.",
+)
+def import_cache(
+ version: str | None,
+ import_all: bool,
+ force: bool,
+ source_dir: Path | None,
+) -> None:
+ """Import mathlib package caches into LeanUp's default cache directory."""
+ if import_all and version:
+ raise click.ClickException("Use either VERSION or --all, not both.")
+ if not import_all and not version:
+ raise click.ClickException("Missing VERSION. Use `leanup mathlib cache import ` or --all.")
+
+ manager = MathlibCacheManager()
+ targets = []
+ if import_all:
+ targets = [entry.version for entry in manager.list_entries(source_dir=source_dir) if entry.importable]
+ if not targets:
+ raise click.ClickException("No importable mathlib caches found.")
+ else:
+ targets = [normalize_lean_version(version)]
+
+ for target in targets:
+ try:
+ packages_dir = manager.import_archive(target, source_dir=source_dir, force=force)
+ except ValueError as exc:
+ raise click.ClickException(str(exc)) from exc
+ click.echo(f"Imported {target} -> {packages_dir}")
diff --git a/leanup/repo/__init__.py b/leanup/repo/__init__.py
index 5099f6e..4061622 100644
--- a/leanup/repo/__init__.py
+++ b/leanup/repo/__init__.py
@@ -2,11 +2,13 @@
from .manager import RepoManager, LeanRepo
from .elan import ElanManager
+from .mathlib_cache import MathlibCacheManager
from .project_setup import LeanProjectSetup, SetupConfig, SetupResult
__all__ = [
'RepoManager',
'ElanManager',
+ 'MathlibCacheManager',
'LeanRepo',
'LeanProjectSetup',
'SetupConfig',
diff --git a/leanup/repo/mathlib_cache.py b/leanup/repo/mathlib_cache.py
new file mode 100644
index 0000000..c37b11c
--- /dev/null
+++ b/leanup/repo/mathlib_cache.py
@@ -0,0 +1,153 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+import os
+import re
+import shutil
+import tarfile
+
+from leanup.const import LEANUP_CACHE_DIR
+from leanup.utils.custom_logger import setup_logger
+
+logger = setup_logger("mathlib_cache")
+
+LEAN_VERSION_PATTERN = re.compile(r"^v?4\.\d+\.\d+$")
+
+
+def normalize_lean_version(version: str) -> str:
+ normalized = version.strip()
+ if not LEAN_VERSION_PATTERN.match(normalized):
+ raise ValueError("Lean version must look like v4.x.x or 4.x.x.")
+ if not normalized.startswith("v"):
+ normalized = f"v{normalized}"
+ return normalized
+
+
+def remove_path(path: Path) -> None:
+ if not path.exists() and not path.is_symlink():
+ return
+ if path.is_symlink() or path.is_file():
+ path.unlink()
+ return
+ shutil.rmtree(path)
+
+
+@dataclass
+class CacheEntry:
+ version: str
+ local_path: Path
+ archive_path: Path | None
+
+ @property
+ def local_available(self) -> bool:
+ return self.local_path.exists()
+
+ @property
+ def importable(self) -> bool:
+ return self.archive_path is not None and self.archive_path.exists()
+
+
+class MathlibCacheManager:
+ def __init__(self, cache_root: Path | None = None):
+ self.cache_root = cache_root or (LEANUP_CACHE_DIR / "setup" / "mathlib")
+
+ def get_local_packages_dir(self, version: str) -> Path:
+ return self.cache_root / normalize_lean_version(version) / "packages"
+
+ def discover_reference_cache_dir(self) -> Path | None:
+ explicit = os.getenv("LEANUP_MATHLIB_CACHE_SOURCE")
+ candidates = []
+ if explicit:
+ candidates.append(Path(explicit).expanduser())
+
+ here = Path(__file__).resolve()
+ for parent in [Path.cwd().resolve(), *Path.cwd().resolve().parents, *here.parents]:
+ candidates.append(parent / "reference" / "Projects" / "cache")
+
+ seen = set()
+ for candidate in candidates:
+ resolved = str(candidate)
+ if resolved in seen:
+ continue
+ seen.add(resolved)
+ if candidate.exists() and candidate.is_dir():
+ return candidate
+ return None
+
+ def get_reference_archive(self, version: str, source_dir: Path | None = None) -> Path | None:
+ normalized = normalize_lean_version(version)
+ source_root = source_dir or self.discover_reference_cache_dir()
+ if not source_root:
+ return None
+ archive = source_root / normalized / "packages.tar.gz"
+ return archive if archive.exists() else None
+
+ def list_entries(self, source_dir: Path | None = None) -> list[CacheEntry]:
+ versions = set()
+ if self.cache_root.exists():
+ for child in self.cache_root.iterdir():
+ if child.is_dir() and LEAN_VERSION_PATTERN.match(child.name):
+ versions.add(normalize_lean_version(child.name))
+
+ reference_root = source_dir or self.discover_reference_cache_dir()
+ if reference_root and reference_root.exists():
+ for child in reference_root.iterdir():
+ if child.is_dir() and LEAN_VERSION_PATTERN.match(child.name):
+ versions.add(normalize_lean_version(child.name))
+
+ return [
+ CacheEntry(
+ version=version,
+ local_path=self.get_local_packages_dir(version),
+ archive_path=self.get_reference_archive(version, source_dir=source_dir),
+ )
+ for version in sorted(versions)
+ ]
+
+ def ensure_local_cache(self, version: str, source_dir: Path | None = None) -> Path | None:
+ local_path = self.get_local_packages_dir(version)
+ if local_path.exists():
+ return local_path
+ archive = self.get_reference_archive(version, source_dir=source_dir)
+ if not archive:
+ return None
+ self.import_archive(version, archive)
+ return local_path
+
+ def import_archive(
+ self,
+ version: str,
+ archive_path: Path | None = None,
+ source_dir: Path | None = None,
+ force: bool = False,
+ ) -> Path:
+ normalized = normalize_lean_version(version)
+ archive = archive_path or self.get_reference_archive(normalized, source_dir=source_dir)
+ if not archive:
+ raise ValueError(f"No reference cache archive found for {normalized}.")
+
+ version_root = self.cache_root / normalized
+ packages_dir = version_root / "packages"
+ if packages_dir.exists():
+ if not force:
+ return packages_dir
+ remove_path(packages_dir)
+
+ version_root.mkdir(parents=True, exist_ok=True)
+ temp_root = version_root / ".importing"
+ remove_path(temp_root)
+ temp_root.mkdir(parents=True, exist_ok=True)
+
+ try:
+ with tarfile.open(archive, "r:gz") as tar:
+ tar.extractall(path=temp_root, filter="data")
+ extracted_packages = temp_root / "packages"
+ if not extracted_packages.exists():
+ raise ValueError(f"Archive {archive} does not contain a packages directory.")
+ remove_path(packages_dir)
+ os.replace(extracted_packages, packages_dir)
+ logger.info(f"Imported mathlib cache {normalized} from {archive}")
+ return packages_dir
+ finally:
+ remove_path(temp_root)
diff --git a/leanup/repo/project_setup.py b/leanup/repo/project_setup.py
index 9af1774..1b89dae 100644
--- a/leanup/repo/project_setup.py
+++ b/leanup/repo/project_setup.py
@@ -6,25 +6,14 @@
import re
import shutil
-from leanup.const import LEANUP_CACHE_DIR
from leanup.repo.elan import ElanManager
+from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version, remove_path
from leanup.repo.manager import LeanRepo
from leanup.utils.basic import working_directory
from leanup.utils.custom_logger import setup_logger
logger = setup_logger("project_setup")
-LEAN_VERSION_PATTERN = re.compile(r"^v?4\.\d+\.\d+$")
-
-
-def normalize_lean_version(version: str) -> str:
- normalized = version.strip()
- if not LEAN_VERSION_PATTERN.match(normalized):
- raise ValueError("Lean version must look like v4.x.x or 4.x.x.")
- if not normalized.startswith("v"):
- normalized = f"v{normalized}"
- return normalized
-
def sanitize_project_name(name: str) -> str:
sanitized = re.sub(r"[^A-Za-z0-9_]", "", name.strip())
@@ -35,15 +24,6 @@ def sanitize_project_name(name: str) -> str:
return sanitized
-def remove_path(path: Path) -> None:
- if not path.exists() and not path.is_symlink():
- return
- if path.is_symlink() or path.is_file():
- path.unlink()
- return
- shutil.rmtree(path)
-
-
@dataclass
class SetupConfig:
target_dir: Path
@@ -77,7 +57,7 @@ def toolchain(self) -> str:
@property
def mathlib_cache_dir(self) -> Path:
- return LEANUP_CACHE_DIR / "setup" / "mathlib" / self.lean_version / "packages"
+ return MathlibCacheManager().get_local_packages_dir(self.lean_version)
def validate(self) -> None:
if self.resolved_dependency_mode not in {"symlink", "build"}:
@@ -99,6 +79,7 @@ class SetupResult:
class LeanProjectSetup:
def __init__(self, elan_manager: ElanManager | None = None):
self.elan_manager = elan_manager or ElanManager()
+ self.cache_manager = MathlibCacheManager()
def setup(self, config: SetupConfig) -> SetupResult:
config.validate()
@@ -173,11 +154,11 @@ def _run_lake_build(self, repo: LeanRepo) -> None:
raise RuntimeError(stderr or stdout or "lake build failed.")
def _link_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None:
- cache_dir = config.mathlib_cache_dir
- if not cache_dir.exists():
+ cache_dir = self.cache_manager.ensure_local_cache(config.lean_version)
+ if not cache_dir:
raise ValueError(
"No cached mathlib packages found for this Lean version. "
- "Run setup with --dependency-mode build first."
+ "Import one with `leanup mathlib cache import ` or run setup with --dependency-mode build first."
)
packages_dir = project_dir / ".lake" / "packages"
@@ -195,7 +176,7 @@ def _refresh_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None
logger.warning("Skipping cache refresh because .lake/packages does not exist.")
return
- cache_dir = config.mathlib_cache_dir
+ cache_dir = self.cache_manager.get_local_packages_dir(config.lean_version)
cache_parent = cache_dir.parent
cache_parent.mkdir(parents=True, exist_ok=True)
diff --git a/tests/test_mathlib_cache.py b/tests/test_mathlib_cache.py
new file mode 100644
index 0000000..2147fc0
--- /dev/null
+++ b/tests/test_mathlib_cache.py
@@ -0,0 +1,145 @@
+from pathlib import Path
+import tarfile
+
+from click.testing import CliRunner
+
+from leanup.cli import cli
+from leanup.repo.mathlib_cache import MathlibCacheManager
+
+
+def _write_reference_archive(root: Path, version: str) -> Path:
+ archive_dir = root / version
+ archive_dir.mkdir(parents=True, exist_ok=True)
+ staging = root / ".staging" / version / "packages" / "mathlib"
+ staging.mkdir(parents=True, exist_ok=True)
+ (staging / "README.md").write_text(f"cache for {version}\n", encoding="utf-8")
+ archive_path = archive_dir / "packages.tar.gz"
+ with tarfile.open(archive_path, "w:gz") as tar:
+ tar.add(staging.parent, arcname="packages")
+ return archive_path
+
+
+def test_mathlib_cache_import_command(monkeypatch, tmp_path):
+ import importlib
+
+ mathlib_cli_module = importlib.import_module("leanup.cli.mathlib")
+ cache_module = importlib.import_module("leanup.repo.mathlib_cache")
+
+ runner = CliRunner()
+ source_dir = tmp_path / "reference-cache"
+ _write_reference_archive(source_dir, "v4.22.0")
+
+ original_cache_root = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
+
+ try:
+ result = runner.invoke(
+ cli,
+ ["mathlib", "cache", "import", "v4.22.0", "--source-dir", str(source_dir)],
+ )
+
+ assert result.exit_code == 0
+ local_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages"
+ assert local_packages.exists()
+ assert (local_packages / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cache for v4.22.0"
+ finally:
+ cache_module.LEANUP_CACHE_DIR = original_cache_root
+
+
+def test_mathlib_cache_list_command(monkeypatch, tmp_path):
+ import importlib
+
+ cache_module = importlib.import_module("leanup.repo.mathlib_cache")
+ runner = CliRunner()
+ source_dir = tmp_path / "reference-cache"
+ _write_reference_archive(source_dir, "v4.22.0")
+
+ original_cache_root = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
+
+ try:
+ local_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.21.0" / "packages"
+ (local_packages / "mathlib").mkdir(parents=True, exist_ok=True)
+
+ result = runner.invoke(
+ cli,
+ ["mathlib", "cache", "list", "--source-dir", str(source_dir)],
+ )
+
+ assert result.exit_code == 0
+ assert "v4.21.0" in result.output
+ assert "v4.22.0" in result.output
+ assert "local" in result.output
+ assert "importable" in result.output
+ finally:
+ cache_module.LEANUP_CACHE_DIR = original_cache_root
+
+
+def test_setup_symlink_imports_reference_cache_when_available(tmp_path):
+ class FakeElanManager:
+ def is_elan_installed(self):
+ return True
+
+ def install_elan(self):
+ return True
+
+ def install_lean(self, version):
+ return True
+
+ def fake_lake_init(self, name, template=None):
+ project_dir = self.cwd / name
+ project_dir.mkdir(parents=True)
+ (project_dir / "lakefile.lean").write_text(f"template={template}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_update(self):
+ packages_dir = self.cwd / ".lake" / "packages"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (self.cwd / "lake-manifest.json").write_text("{}\n", encoding="utf-8")
+ return "", "", 0
+
+ def fake_lake_build(self):
+ return "", "", 0
+
+ from leanup.repo import mathlib_cache as cache_module
+ from leanup.repo.manager import LeanRepo
+ from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
+
+ source_dir = tmp_path / "reference-cache"
+ _write_reference_archive(source_dir, "v4.22.0")
+
+ original_cache_root = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
+ original_source = cache_module.os.getenv("LEANUP_MATHLIB_CACHE_SOURCE")
+ original_lake_init = LeanRepo.lake_init
+ original_lake_update = LeanRepo.lake_update
+ original_lake_build = LeanRepo.lake_build
+
+ try:
+ cache_module.os.environ["LEANUP_MATHLIB_CACHE_SOURCE"] = str(source_dir)
+ LeanRepo.lake_init = fake_lake_init
+ LeanRepo.lake_update = fake_lake_update
+ LeanRepo.lake_build = fake_lake_build
+
+ manager = LeanProjectSetup(elan_manager=FakeElanManager())
+ manager.cache_manager = MathlibCacheManager()
+
+ target = tmp_path / "ImportedDemo"
+ config = SetupConfig(target_dir=target, lean_version="v4.22.0", dependency_mode="symlink")
+
+ result = manager.setup(config)
+
+ packages_link = target / ".lake" / "packages"
+ assert result.used_cache is True
+ assert packages_link.is_symlink()
+ assert packages_link.resolve() == config.mathlib_cache_dir
+ assert (packages_link / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cache for v4.22.0"
+ finally:
+ if original_source is None:
+ cache_module.os.environ.pop("LEANUP_MATHLIB_CACHE_SOURCE", None)
+ else:
+ cache_module.os.environ["LEANUP_MATHLIB_CACHE_SOURCE"] = original_source
+ cache_module.LEANUP_CACHE_DIR = original_cache_root
+ LeanRepo.lake_init = original_lake_init
+ LeanRepo.lake_update = original_lake_update
+ LeanRepo.lake_build = original_lake_build
diff --git a/tests/test_setup.py b/tests/test_setup.py
index 44f1d9e..2c9df46 100644
--- a/tests/test_setup.py
+++ b/tests/test_setup.py
@@ -105,19 +105,20 @@ def fake_setup(self, config):
def test_setup_config_prefers_symlink_when_cache_exists(tmp_path):
- from leanup.repo import project_setup as project_setup_module
+ from leanup.repo.mathlib_cache import MathlibCacheManager
+ from leanup.repo import mathlib_cache as cache_module
- original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
- project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+ original_cache_dir = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "cache"
try:
config = SetupConfig(target_dir=tmp_path / "Demo", lean_version="v4.27.0")
assert config.resolved_dependency_mode == "build"
- config.mathlib_cache_dir.mkdir(parents=True, exist_ok=True)
+ MathlibCacheManager().get_local_packages_dir("v4.27.0").mkdir(parents=True, exist_ok=True)
assert config.resolved_dependency_mode == "symlink"
finally:
- project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+ cache_module.LEANUP_CACHE_DIR = original_cache_dir
def test_setup_build_mode_populates_shared_cache(tmp_path):
@@ -149,10 +150,10 @@ def fake_lake_build(self):
build_dir.mkdir(parents=True, exist_ok=True)
return "", "", 0
- from leanup.repo import project_setup as project_setup_module
+ from leanup.repo import mathlib_cache as cache_module
- original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
- project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+ original_cache_dir = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "cache"
try:
from leanup.repo.manager import LeanRepo
@@ -179,7 +180,7 @@ def fake_lake_build(self):
assert config.mathlib_cache_dir.exists()
assert (config.mathlib_cache_dir / "mathlib" / "README.md").exists()
finally:
- project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+ cache_module.LEANUP_CACHE_DIR = original_cache_dir
LeanRepo.lake_init = original_lake_init
LeanRepo.lake_update = original_lake_update
LeanRepo.lake_build = original_lake_build
@@ -211,10 +212,10 @@ def fake_lake_update(self):
def fake_lake_build(self):
return "", "", 0
- from leanup.repo import project_setup as project_setup_module
+ from leanup.repo import mathlib_cache as cache_module
- original_cache_dir = project_setup_module.LEANUP_CACHE_DIR
- project_setup_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+ original_cache_dir = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "cache"
try:
from leanup.repo.manager import LeanRepo
@@ -226,7 +227,7 @@ def fake_lake_build(self):
LeanRepo.lake_update = fake_lake_update
LeanRepo.lake_build = fake_lake_build
- cached_packages = project_setup_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.27.0" / "packages" / "mathlib"
+ cached_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.27.0" / "packages" / "mathlib"
cached_packages.mkdir(parents=True, exist_ok=True)
(cached_packages / "README.md").write_text("cached\n", encoding="utf-8")
@@ -242,7 +243,7 @@ def fake_lake_build(self):
assert packages_link.resolve() == config.mathlib_cache_dir
assert (packages_link / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cached"
finally:
- project_setup_module.LEANUP_CACHE_DIR = original_cache_dir
+ cache_module.LEANUP_CACHE_DIR = original_cache_dir
LeanRepo.lake_init = original_lake_init
LeanRepo.lake_update = original_lake_update
LeanRepo.lake_build = original_lake_build
From 64b73ea131043d60d771b62942c39fb5d8480600 Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Tue, 7 Apr 2026 22:22:49 +0800
Subject: [PATCH 5/8] feat: streamline mathlib setup and cache tooling
---
README.md | 22 +--
docs/getting-started/quickstart.md | 16 +-
docs/index.md | 2 +-
leanup/cli/mathlib.py | 98 +++-------
leanup/repo/mathlib_cache.py | 107 +++-------
leanup/repo/project_setup.py | 127 +++++++++---
.../manifests/v4.14.0/lake-manifest.json | 95 +++++++++
.../manifests/v4.17.0/lake-manifest.json | 95 +++++++++
.../manifests/v4.19.0/lake-manifest.json | 95 +++++++++
.../manifests/v4.21.0/lake-manifest.json | 95 +++++++++
.../manifests/v4.22.0/lake-manifest.json | 95 +++++++++
.../manifests/v4.27.0/lake-manifest.json | 95 +++++++++
.../templates/mathlib/v4.xx.0/Basic.lean.tmpl | 1 +
.../templates/mathlib/v4.xx.0/README.md.tmpl | 1 +
.../mathlib/v4.xx.0/lakefile.lean.tmpl | 12 ++
.../templates/mathlib/v4.xx.0/root.lean.tmpl | 3 +
tests/test_mathlib_cache.py | 145 --------------
tests/test_mathlib_cache_cli.py | 37 ++++
tests/test_setup.py | 185 +++++++++++++++++-
19 files changed, 971 insertions(+), 355 deletions(-)
create mode 100644 leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
create mode 100644 leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl
create mode 100644 leanup/templates/mathlib/v4.xx.0/README.md.tmpl
create mode 100644 leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl
create mode 100644 leanup/templates/mathlib/v4.xx.0/root.lean.tmpl
delete mode 100644 tests/test_mathlib_cache.py
create mode 100644 tests/test_mathlib_cache_cli.py
diff --git a/README.md b/README.md
index 4b551cf..22b16a6 100644
--- a/README.md
+++ b/README.md
@@ -114,32 +114,20 @@ leanup setup ./Demo --lean-version v4.27.0 --name MyDemo --force
- `--dependency-mode symlink` 只在启用 `mathlib` 时可用
- 默认缓存目录为 `LEANUP_CACHE_DIR/setup/mathlib//packages`
-- 在当前 workspace 中,默认也会尝试从 `reference/Projects/cache//packages.tar.gz` 自动导入缓存
-- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
-- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
+- 默认行为偏向缓存复用:如果已有 `packages` 缓存就直接链接,否则执行 `lake update`、`lake exe cache get`,再把 `.lake/packages` 写回缓存
- `setup` 会确保对应 Lean toolchain 已通过 `elan` 安装
### 管理 mathlib 缓存
```bash
-# 查看 LeanUp 已有缓存,以及 reference 中可导入的缓存
+# 查看 LeanUp 已有缓存版本
leanup mathlib cache list
-# 只看本地已导入缓存
-leanup mathlib cache list --local-only
-
-# 只看可从 reference 导入的缓存
-leanup mathlib cache list --importable-only
-
-# 导入某个版本的 reference cache 到 LeanUp 默认缓存目录
-leanup mathlib cache import v4.22.0
-
-# 一次性导入全部 reference cache
-leanup mathlib cache import --all
+# 进入一个 Lean 仓库后,把当前仓库的 .lake/packages 打包到指定目录
+cd /path/to/repo
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
```
-如果需要覆盖 reference cache 来源目录,可以设置环境变量 `LEANUP_MATHLIB_CACHE_SOURCE`,或者在命令里传 `--source-dir`。
-
### 交互式安装
使用 `leanup repo install -i` 时,您可以配置:
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index d4b0266..72bd6ff 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -40,28 +40,22 @@ leanup setup ./PlainDemo --lean-version v4.27.0 --no-mathlib
- 共享缓存默认放在 `LEANUP_CACHE_DIR/setup/mathlib//packages`
- Linux 默认通常对应 `~/.cache/leanup/setup/mathlib//packages`
-- 在当前 workspace 中,默认也会尝试从 `reference/Projects/cache//packages.tar.gz` 自动导入缓存
- `symlink` 模式只对 `mathlib` 项目开放
-- 默认行为是:有缓存就复用,没有缓存就自动构建并刷新缓存
-- 显式指定 `--dependency-mode symlink` 时,如果当前版本还没有缓存,会直接报错
+- 默认行为偏向缓存复用:如果已有 `packages` 缓存就直接链接,否则执行 `lake update`、`lake exe cache get`,再把 `.lake/packages` 写回缓存
- `setup` 会自动确保 `elan` 和目标 Lean toolchain 已安装
- 如果你需要直接透传给 `elan`,仍然可以使用 `leanup elan ...`
### 管理 mathlib 缓存
```bash
-# 查看 LeanUp 已有缓存,以及 reference 中可导入的缓存
+# 查看 LeanUp 已有缓存版本
leanup mathlib cache list
-# 导入某个版本的 reference cache
-leanup mathlib cache import v4.22.0
-
-# 导入全部 reference cache
-leanup mathlib cache import --all
+# 进入当前仓库后,打包本仓库的 .lake/packages 到指定目录
+cd /path/to/repo
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
```
-如果需要覆盖 reference cache 来源目录,可以设置 `LEANUP_MATHLIB_CACHE_SOURCE`,或者传 `--source-dir`。
-
### 仓库管理
```bash
diff --git a/docs/index.md b/docs/index.md
index dc64650..5a6d87c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -5,7 +5,7 @@
## 功能特性
- `leanup setup`:快速创建固定 Lean 版本项目,支持 mathlib 共享缓存
-- `leanup mathlib cache ...`:查看和导入 mathlib 共享缓存
+- `leanup mathlib cache list`:查看本地 mathlib 共享缓存版本
- `leanup repo install`:安装 Lean 仓库,支持命令优先、交互补参
- `leanup repo list`:查看已安装仓库
diff --git a/leanup/cli/mathlib.py b/leanup/cli/mathlib.py
index 1add611..121dede 100644
--- a/leanup/cli/mathlib.py
+++ b/leanup/cli/mathlib.py
@@ -18,83 +18,47 @@ def cache() -> None:
@cache.command(name="list")
-@click.option(
- "--local-only",
- is_flag=True,
- help="Show only caches already imported into LeanUp's cache directory.",
-)
-@click.option(
- "--importable-only",
- is_flag=True,
- help="Show only versions that can be imported from the reference cache source.",
-)
-@click.option(
- "--source-dir",
- type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
- help="Override the reference cache directory to scan for packages.tar.gz archives.",
-)
-def list_cache(local_only: bool, importable_only: bool, source_dir: Path | None) -> None:
+def list_cache() -> None:
"""List available mathlib caches."""
manager = MathlibCacheManager()
- entries = manager.list_entries(source_dir=source_dir)
-
- filtered = []
- for entry in entries:
- if local_only and not entry.local_available:
- continue
- if importable_only and not entry.importable:
- continue
- filtered.append(entry)
+ entries = manager.list_entries()
- if not filtered:
+ if not entries:
click.echo("No mathlib caches found.")
return
- for entry in filtered:
- status = []
- if entry.local_available:
- status.append("local")
- if entry.importable:
- status.append("importable")
- click.echo(f"{entry.version}\t{', '.join(status) or 'unavailable'}")
- click.echo(f" local: {entry.local_path}")
- if entry.archive_path:
- click.echo(f" archive: {entry.archive_path}")
+ for entry in entries:
+ click.echo(entry.version)
-@cache.command(name="import")
-@click.argument("version", required=False)
-@click.option("--all", "import_all", is_flag=True, help="Import all importable cache archives.")
-@click.option("--force", is_flag=True, help="Replace an existing imported cache.")
+@cache.command(name="pack")
@click.option(
- "--source-dir",
+ "--repo-dir",
type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
- help="Override the reference cache directory containing versioned packages.tar.gz archives.",
+ default=Path.cwd,
+ help="Lean repository directory containing .lake/packages.",
)
-def import_cache(
- version: str | None,
- import_all: bool,
- force: bool,
- source_dir: Path | None,
-) -> None:
- """Import mathlib package caches into LeanUp's default cache directory."""
- if import_all and version:
- raise click.ClickException("Use either VERSION or --all, not both.")
- if not import_all and not version:
- raise click.ClickException("Missing VERSION. Use `leanup mathlib cache import ` or --all.")
-
+@click.option(
+ "--output-dir",
+ type=click.Path(path_type=Path, file_okay=False, dir_okay=True),
+ required=True,
+ help="Directory where /packages.tar.gz will be written.",
+)
+@click.option(
+ "--lean-version",
+ required=True,
+ help="Lean version like v4.22.0.",
+)
+def pack_cache(repo_dir: Path, output_dir: Path, lean_version: str) -> None:
+ """Pack current repository .lake/packages into a versioned cache archive."""
manager = MathlibCacheManager()
- targets = []
- if import_all:
- targets = [entry.version for entry in manager.list_entries(source_dir=source_dir) if entry.importable]
- if not targets:
- raise click.ClickException("No importable mathlib caches found.")
- else:
- targets = [normalize_lean_version(version)]
+ version = normalize_lean_version(lean_version)
+ packages_dir = repo_dir / ".lake" / "packages"
+ archive = output_dir / version / "packages.tar.gz"
+
+ try:
+ packed = manager.pack_packages_archive(packages_dir, archive)
+ except ValueError as exc:
+ raise click.ClickException(str(exc)) from exc
- for target in targets:
- try:
- packages_dir = manager.import_archive(target, source_dir=source_dir, force=force)
- except ValueError as exc:
- raise click.ClickException(str(exc)) from exc
- click.echo(f"Imported {target} -> {packages_dir}")
+ click.echo(str(packed))
diff --git a/leanup/repo/mathlib_cache.py b/leanup/repo/mathlib_cache.py
index c37b11c..390991d 100644
--- a/leanup/repo/mathlib_cache.py
+++ b/leanup/repo/mathlib_cache.py
@@ -2,7 +2,6 @@
from dataclasses import dataclass
from pathlib import Path
-import os
import re
import shutil
import tarfile
@@ -37,16 +36,11 @@ def remove_path(path: Path) -> None:
class CacheEntry:
version: str
local_path: Path
- archive_path: Path | None
@property
def local_available(self) -> bool:
return self.local_path.exists()
- @property
- def importable(self) -> bool:
- return self.archive_path is not None and self.archive_path.exists()
-
class MathlibCacheManager:
def __init__(self, cache_root: Path | None = None):
@@ -55,99 +49,54 @@ def __init__(self, cache_root: Path | None = None):
def get_local_packages_dir(self, version: str) -> Path:
return self.cache_root / normalize_lean_version(version) / "packages"
- def discover_reference_cache_dir(self) -> Path | None:
- explicit = os.getenv("LEANUP_MATHLIB_CACHE_SOURCE")
- candidates = []
- if explicit:
- candidates.append(Path(explicit).expanduser())
-
- here = Path(__file__).resolve()
- for parent in [Path.cwd().resolve(), *Path.cwd().resolve().parents, *here.parents]:
- candidates.append(parent / "reference" / "Projects" / "cache")
-
- seen = set()
- for candidate in candidates:
- resolved = str(candidate)
- if resolved in seen:
- continue
- seen.add(resolved)
- if candidate.exists() and candidate.is_dir():
- return candidate
- return None
-
- def get_reference_archive(self, version: str, source_dir: Path | None = None) -> Path | None:
- normalized = normalize_lean_version(version)
- source_root = source_dir or self.discover_reference_cache_dir()
- if not source_root:
- return None
- archive = source_root / normalized / "packages.tar.gz"
- return archive if archive.exists() else None
-
- def list_entries(self, source_dir: Path | None = None) -> list[CacheEntry]:
+ def list_entries(self) -> list[CacheEntry]:
versions = set()
if self.cache_root.exists():
for child in self.cache_root.iterdir():
if child.is_dir() and LEAN_VERSION_PATTERN.match(child.name):
versions.add(normalize_lean_version(child.name))
- reference_root = source_dir or self.discover_reference_cache_dir()
- if reference_root and reference_root.exists():
- for child in reference_root.iterdir():
- if child.is_dir() and LEAN_VERSION_PATTERN.match(child.name):
- versions.add(normalize_lean_version(child.name))
-
return [
CacheEntry(
version=version,
local_path=self.get_local_packages_dir(version),
- archive_path=self.get_reference_archive(version, source_dir=source_dir),
)
for version in sorted(versions)
]
- def ensure_local_cache(self, version: str, source_dir: Path | None = None) -> Path | None:
+ def ensure_local_cache(self, version: str) -> Path | None:
local_path = self.get_local_packages_dir(version)
if local_path.exists():
return local_path
- archive = self.get_reference_archive(version, source_dir=source_dir)
- if not archive:
- return None
- self.import_archive(version, archive)
- return local_path
-
- def import_archive(
- self,
- version: str,
- archive_path: Path | None = None,
- source_dir: Path | None = None,
- force: bool = False,
- ) -> Path:
- normalized = normalize_lean_version(version)
- archive = archive_path or self.get_reference_archive(normalized, source_dir=source_dir)
- if not archive:
- raise ValueError(f"No reference cache archive found for {normalized}.")
+ return None
+ def refresh_local_cache(self, version: str, source_dir: Path, force: bool = True) -> Path:
+ normalized = normalize_lean_version(version)
version_root = self.cache_root / normalized
packages_dir = version_root / "packages"
- if packages_dir.exists():
- if not force:
- return packages_dir
- remove_path(packages_dir)
+ if packages_dir.exists() and not force:
+ return packages_dir
version_root.mkdir(parents=True, exist_ok=True)
- temp_root = version_root / ".importing"
+ temp_root = version_root / ".packages.tmp"
remove_path(temp_root)
- temp_root.mkdir(parents=True, exist_ok=True)
-
- try:
- with tarfile.open(archive, "r:gz") as tar:
- tar.extractall(path=temp_root, filter="data")
- extracted_packages = temp_root / "packages"
- if not extracted_packages.exists():
- raise ValueError(f"Archive {archive} does not contain a packages directory.")
- remove_path(packages_dir)
- os.replace(extracted_packages, packages_dir)
- logger.info(f"Imported mathlib cache {normalized} from {archive}")
- return packages_dir
- finally:
- remove_path(temp_root)
+ shutil.copytree(source_dir, temp_root, symlinks=True)
+
+ remove_path(packages_dir)
+ temp_root.replace(packages_dir)
+ logger.info(f"Refreshed mathlib cache {normalized} from {source_dir}")
+ return packages_dir
+
+ def pack_packages_archive(self, packages_dir: Path, output_file: Path) -> Path:
+ if not packages_dir.exists() or not packages_dir.is_dir():
+ raise ValueError(f"Packages directory not found: {packages_dir}")
+
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ if output_file.exists():
+ output_file.unlink()
+
+ with tarfile.open(output_file, "w:gz", dereference=False) as tar:
+ tar.add(packages_dir, arcname="packages", recursive=True)
+
+ logger.info(f"Packed {packages_dir} -> {output_file}")
+ return output_file
diff --git a/leanup/repo/project_setup.py b/leanup/repo/project_setup.py
index 1b89dae..1cd2ba1 100644
--- a/leanup/repo/project_setup.py
+++ b/leanup/repo/project_setup.py
@@ -1,10 +1,11 @@
from __future__ import annotations
from dataclasses import dataclass
-import os
+import json
from pathlib import Path
import re
import shutil
+import tempfile
from leanup.repo.elan import ElanManager
from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version, remove_path
@@ -13,6 +14,8 @@
from leanup.utils.custom_logger import setup_logger
logger = setup_logger("project_setup")
+TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "templates" / "mathlib" / "v4.xx.0"
+BUNDLED_MANIFEST_ROOT = Path(__file__).resolve().parent.parent / "templates" / "mathlib" / "manifests"
def sanitize_project_name(name: str) -> str:
@@ -49,7 +52,7 @@ def resolved_dependency_mode(self) -> str:
return self.dependency_mode
if not self.mathlib:
return "build"
- return "symlink" if self.mathlib_cache_dir.exists() else "build"
+ return "symlink"
@property
def toolchain(self) -> str:
@@ -87,32 +90,28 @@ def setup(self, config: SetupConfig) -> SetupResult:
self._ensure_toolchain(config.lean_version)
with working_directory() as temp_dir:
- temp_root = LeanRepo(temp_dir)
- stdout, stderr, returncode = temp_root.lake_init(
- config.project_name,
- config.template,
- )
- if returncode != 0:
- raise RuntimeError(stderr or stdout or "Failed to initialize Lean project.")
-
project_dir = temp_dir / config.project_name
+ self._create_project_skeleton(config, project_dir)
project = LeanRepo(project_dir)
self._write_toolchain(project_dir, config.toolchain)
+ self._copy_reference_manifest(config, project_dir)
used_cache = False
cache_dir = config.mathlib_cache_dir if config.mathlib else None
if config.mathlib and config.resolved_dependency_mode == "symlink":
- self._link_mathlib_cache(config, project_dir)
- used_cache = True
+ used_cache = self._prepare_mathlib_cache(config, project_dir)
- if config.mathlib:
+ if config.mathlib and self._should_run_lake_update(config, project_dir):
self._run_lake_update(project)
+ self._run_lake_cache_get(project)
+ self._refresh_mathlib_cache(config, project_dir)
+ if config.resolved_dependency_mode == "symlink":
+ self._link_mathlib_cache(config, project_dir)
+ used_cache = True
self._run_lake_build(project)
-
- if config.mathlib and config.resolved_dependency_mode == "build":
- self._refresh_mathlib_cache(config, project_dir)
+ self._verify_mathlib_project(project_dir)
shutil.move(str(project_dir), str(config.target_dir))
@@ -140,6 +139,60 @@ def _ensure_toolchain(self, version: str) -> None:
if not self.elan_manager.install_lean(version):
raise RuntimeError(f"Failed to install Lean toolchain {version}.")
+ def _create_project_skeleton(self, config: SetupConfig, project_dir: Path) -> None:
+ project_dir.mkdir(parents=True, exist_ok=True)
+ if config.mathlib:
+ self._render_mathlib_template(config, project_dir)
+ return
+
+ repo = LeanRepo(project_dir)
+ stdout, stderr, returncode = repo.lake_init(config.project_name, config.template)
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "Failed to initialize Lean project.")
+
+ def _render_mathlib_template(self, config: SetupConfig, project_dir: Path) -> None:
+ context = {
+ "project_name": config.project_name,
+ "lean_version": config.lean_version,
+ }
+ templates = {
+ "README.md.tmpl": project_dir / "README.md",
+ "lakefile.lean.tmpl": project_dir / "lakefile.lean",
+ "root.lean.tmpl": project_dir / f"{config.project_name}.lean",
+ "Basic.lean.tmpl": project_dir / config.project_name / "Basic.lean",
+ }
+ for template_name, output_path in templates.items():
+ content = (TEMPLATE_ROOT / template_name).read_text(encoding="utf-8")
+ output_path.parent.mkdir(parents=True, exist_ok=True)
+ output_path.write_text(content.format(**context), encoding="utf-8")
+
+ def _bundled_manifest_path(self, version: str) -> Path | None:
+ candidate = BUNDLED_MANIFEST_ROOT / normalize_lean_version(version) / "lake-manifest.json"
+ if candidate.exists():
+ return candidate
+ return None
+
+ def _copy_reference_manifest(self, config: SetupConfig, project_dir: Path) -> None:
+ if not config.mathlib:
+ return
+ bundled_manifest = self._bundled_manifest_path(config.lean_version)
+ if bundled_manifest:
+ manifest_path = project_dir / "lake-manifest.json"
+ shutil.copy2(bundled_manifest, manifest_path)
+ self._rewrite_manifest_name(manifest_path, config.project_name)
+
+ def _rewrite_manifest_name(self, manifest_path: Path, project_name: str) -> None:
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
+ manifest["name"] = project_name
+ manifest_path.write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
+
+ def _should_run_lake_update(self, config: SetupConfig, project_dir: Path) -> bool:
+ if not config.mathlib:
+ return False
+ manifest = project_dir / "lake-manifest.json"
+ packages = project_dir / ".lake" / "packages"
+ return not (manifest.exists() and packages.exists())
+
def _write_toolchain(self, project_dir: Path, toolchain: str) -> None:
(project_dir / "lean-toolchain").write_text(toolchain + "\n", encoding="utf-8")
@@ -148,18 +201,39 @@ def _run_lake_update(self, repo: LeanRepo) -> None:
if returncode != 0:
raise RuntimeError(stderr or stdout or "lake update failed.")
+ def _run_lake_cache_get(self, repo: LeanRepo) -> None:
+ stdout, stderr, returncode = repo.lake(["exe", "cache", "get"])
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "lake exe cache get failed.")
+
def _run_lake_build(self, repo: LeanRepo) -> None:
stdout, stderr, returncode = repo.lake_build()
if returncode != 0:
raise RuntimeError(stderr or stdout or "lake build failed.")
- def _link_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None:
+ def _verify_mathlib_project(self, project_dir: Path) -> None:
+ with tempfile.NamedTemporaryFile("w", suffix=".lean", delete=False, encoding="utf-8") as handle:
+ handle.write("import Mathlib.Init\n#eval Lean.versionString\n")
+ probe = Path(handle.name)
+ try:
+ repo = LeanRepo(project_dir)
+ stdout, stderr, returncode = repo.lake_env_lean(probe, json=False)
+ if returncode != 0:
+ raise RuntimeError(stderr or stdout or "verification failed.")
+ finally:
+ if probe.exists():
+ probe.unlink()
+
+ def _prepare_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> bool:
cache_dir = self.cache_manager.ensure_local_cache(config.lean_version)
if not cache_dir:
- raise ValueError(
- "No cached mathlib packages found for this Lean version. "
- "Import one with `leanup mathlib cache import ` or run setup with --dependency-mode build first."
- )
+ return False
+
+ self._link_mathlib_cache(config, project_dir)
+ return True
+
+ def _link_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None:
+ cache_dir = self.cache_manager.get_local_packages_dir(config.lean_version)
packages_dir = project_dir / ".lake" / "packages"
packages_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -176,13 +250,4 @@ def _refresh_mathlib_cache(self, config: SetupConfig, project_dir: Path) -> None
logger.warning("Skipping cache refresh because .lake/packages does not exist.")
return
- cache_dir = self.cache_manager.get_local_packages_dir(config.lean_version)
- cache_parent = cache_dir.parent
- cache_parent.mkdir(parents=True, exist_ok=True)
-
- temp_cache_dir = cache_parent / f".{cache_dir.name}.tmp"
- remove_path(temp_cache_dir)
- shutil.copytree(source_dir, temp_cache_dir, symlinks=True)
-
- remove_path(cache_dir)
- os.replace(temp_cache_dir, cache_dir)
+ self.cache_manager.refresh_local_cache(config.lean_version, source_dir)
diff --git a/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
new file mode 100644
index 0000000..efa9181
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "4bbdccd9c5f862bf90ff12f0a9e2c8be032b9a84",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.14.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "42dc02bdbc5d0c2f395718462a76c3d87318f7fa",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "d7caecce0d0f003fd5e9cce9a61f1dd6ba83142b",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "519e509a28864af5bed98033dd33b95cf08e9aa7",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.14.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "68280daef58803f68368eb2e53046dabcd270c9d",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.47",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "5a0ec8588855265ade536f35bcdcf0fb24fd6030",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.14.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "303b23fbcea94ac4f96e590c1cad6618fd4f5f41",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "8d6c853f11a5172efa0e96b9f2be1a83d861cdd9",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.14.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "726b3c9ad13acca724d4651f14afc4804a7b0e4d",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
new file mode 100644
index 0000000..4c9e941
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "5269898d6a51d047931107c8d72d934d8d5d3753",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.17.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "c708be04267e3e995a14ac0d08b1530579c1525a",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "0c169a0d55fef3763cfb3099eafd7b884ec7e41d",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "0447b0a7b7f41f0a1749010db3f222e4a96f9d30",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "799f6986de9f61b784ff7be8f6a8b101045b8ffd",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.52",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "56a2c80b209c253e0281ac4562a92122b457dcc0",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "95561f7a5811fae6a309e4a1bbe22a0a4a98bf03",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "efcc7d9bd9936ecdc625baf0d033b60866565cd5",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "e7fd1a415c80985ade02a021172834ca2139b0ca",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
new file mode 100644
index 0000000..231ea65
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "c44e0c8ee63ca166450922a373c7409c5d26b00b",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.19.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "77e08eddc486491d7b9e470926b3dbe50319451a",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "25078369972d295301f5a1e53c3e5850cf6d9d4c",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "e6a9f0f5ee3ccf7443a0070f92b62f8db12ae82b",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "c4919189477c3221e6a204008998b0d724f49904",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.57",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "5d50b08dedd7d69b3d9b3176e0d58a23af228884",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "fa4f7f15d97591a9cf3aa7724ba371c7fc6dda02",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "f5d04a9c4973d401c8c92500711518f7c656f034",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "02dbd02bc00ec4916e99b04b2245b30200e200d0",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
new file mode 100644
index 0000000..b1363cf
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "308445d7985027f538e281e18df29ca16ede2ba3",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.21.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "c4aa78186d388e50a436e8362b947bae125a2933",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "6c62474116f525d2814f0157bb468bf3a4f9f120",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "d07bd64f1910f1cc5e4cc87b6b9c590080e7a457",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "6980f6ca164de593cb77cd03d8eac549cc444156",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.62",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "8ff27701d003456fd59f13a9212431239d902aef",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "e9c65db4823976353cd0bb03199a172719efbeb7",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "8d2067bf518731a70a255d4a61b5c103922c772e",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "7c6aef5f75a43ebbba763b44d535175a1b04c9e0",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
new file mode 100644
index 0000000..19c996c
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "79e94a093aff4a60fb1b1f92d9681e407124c2ca",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "b100ad4c5d74a464f497aaa8e7c74d86bf39a56f",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "99657ad92e23804e279f77ea6dbdeebaa1317b98",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "eb164a46de87078f27640ee71e6c3841defc2484",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "1253a071e6939b0faf5c09d2b30b0bfc79dae407",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.68",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "1256a18522728c2eeed6109b02dd2b8f207a2a3c",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "917bfa5064b812b7fbd7112d018ea0b4def25ab3",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "240676e9568c254a69be94801889d4b13f3b249f",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.22.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "c682c91d2d4dd59a7187e2ab977ac25bd1f87329",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
new file mode 100644
index 0000000..6769981
--- /dev/null
+++ b/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
@@ -0,0 +1,95 @@
+{"version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages":
+ [{"url": "https://github.com/leanprover-community/mathlib4.git",
+ "type": "git",
+ "subDir": null,
+ "scope": "",
+ "rev": "a3a10db0e9d66acbebf76c5e6a135066525ac900",
+ "name": "mathlib",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.27.0",
+ "inherited": false,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/plausible",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "009dc1e6f2feb2c96c081537d80a0905b2c6498f",
+ "name": "plausible",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/LeanSearchClient",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "5ce7f0a355f522a952a3d678d696bd563bb4fd28",
+ "name": "LeanSearchClient",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/import-graph",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "8f497d55985a189cea8020d9dc51260af1e41ad2",
+ "name": "importGraph",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/ProofWidgets4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "c04225ee7c0585effbd933662b3151f01b600e40",
+ "name": "proofwidgets",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v0.0.85",
+ "inherited": true,
+ "configFile": "lakefile.lean"},
+ {"url": "https://github.com/leanprover-community/aesop",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "cb837cc26236ada03c81837bebe0acd9c70ced7d",
+ "name": "aesop",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/quote4",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "bd58c9efe2086d56ca361807014141a860ddbf8c",
+ "name": "Qq",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "master",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover-community/batteries",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover-community",
+ "rev": "b25b36a7caf8e237e7d1e6121543078a06777c8a",
+ "name": "batteries",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "main",
+ "inherited": true,
+ "configFile": "lakefile.toml"},
+ {"url": "https://github.com/leanprover/lean4-cli",
+ "type": "git",
+ "subDir": null,
+ "scope": "leanprover",
+ "rev": "55c37290ff6186e2e965d68cf853a57c0702db82",
+ "name": "Cli",
+ "manifestFile": "lake-manifest.json",
+ "inputRev": "v4.27.0",
+ "inherited": true,
+ "configFile": "lakefile.toml"}],
+ "name": "lean4web",
+ "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl b/leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl
new file mode 100644
index 0000000..99415d9
--- /dev/null
+++ b/leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl
@@ -0,0 +1 @@
+def hello := "world"
diff --git a/leanup/templates/mathlib/v4.xx.0/README.md.tmpl b/leanup/templates/mathlib/v4.xx.0/README.md.tmpl
new file mode 100644
index 0000000..1ec9149
--- /dev/null
+++ b/leanup/templates/mathlib/v4.xx.0/README.md.tmpl
@@ -0,0 +1 @@
+# {project_name}
diff --git a/leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl b/leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl
new file mode 100644
index 0000000..951a48e
--- /dev/null
+++ b/leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl
@@ -0,0 +1,12 @@
+import Lake
+open Lake DSL
+
+package «{project_name}» where
+ -- add any additional package configuration options here
+
+require mathlib from git
+ "https://github.com/leanprover-community/mathlib4.git" @ "{lean_version}"
+
+@[default_target]
+lean_lib «{project_name}» where
+ -- add any library configuration options here
diff --git a/leanup/templates/mathlib/v4.xx.0/root.lean.tmpl b/leanup/templates/mathlib/v4.xx.0/root.lean.tmpl
new file mode 100644
index 0000000..b081467
--- /dev/null
+++ b/leanup/templates/mathlib/v4.xx.0/root.lean.tmpl
@@ -0,0 +1,3 @@
+-- This module serves as the root of the `{project_name}` library.
+-- Import modules here that should be built as part of the library.
+import {project_name}.Basic
diff --git a/tests/test_mathlib_cache.py b/tests/test_mathlib_cache.py
deleted file mode 100644
index 2147fc0..0000000
--- a/tests/test_mathlib_cache.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from pathlib import Path
-import tarfile
-
-from click.testing import CliRunner
-
-from leanup.cli import cli
-from leanup.repo.mathlib_cache import MathlibCacheManager
-
-
-def _write_reference_archive(root: Path, version: str) -> Path:
- archive_dir = root / version
- archive_dir.mkdir(parents=True, exist_ok=True)
- staging = root / ".staging" / version / "packages" / "mathlib"
- staging.mkdir(parents=True, exist_ok=True)
- (staging / "README.md").write_text(f"cache for {version}\n", encoding="utf-8")
- archive_path = archive_dir / "packages.tar.gz"
- with tarfile.open(archive_path, "w:gz") as tar:
- tar.add(staging.parent, arcname="packages")
- return archive_path
-
-
-def test_mathlib_cache_import_command(monkeypatch, tmp_path):
- import importlib
-
- mathlib_cli_module = importlib.import_module("leanup.cli.mathlib")
- cache_module = importlib.import_module("leanup.repo.mathlib_cache")
-
- runner = CliRunner()
- source_dir = tmp_path / "reference-cache"
- _write_reference_archive(source_dir, "v4.22.0")
-
- original_cache_root = cache_module.LEANUP_CACHE_DIR
- cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
-
- try:
- result = runner.invoke(
- cli,
- ["mathlib", "cache", "import", "v4.22.0", "--source-dir", str(source_dir)],
- )
-
- assert result.exit_code == 0
- local_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages"
- assert local_packages.exists()
- assert (local_packages / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cache for v4.22.0"
- finally:
- cache_module.LEANUP_CACHE_DIR = original_cache_root
-
-
-def test_mathlib_cache_list_command(monkeypatch, tmp_path):
- import importlib
-
- cache_module = importlib.import_module("leanup.repo.mathlib_cache")
- runner = CliRunner()
- source_dir = tmp_path / "reference-cache"
- _write_reference_archive(source_dir, "v4.22.0")
-
- original_cache_root = cache_module.LEANUP_CACHE_DIR
- cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
-
- try:
- local_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.21.0" / "packages"
- (local_packages / "mathlib").mkdir(parents=True, exist_ok=True)
-
- result = runner.invoke(
- cli,
- ["mathlib", "cache", "list", "--source-dir", str(source_dir)],
- )
-
- assert result.exit_code == 0
- assert "v4.21.0" in result.output
- assert "v4.22.0" in result.output
- assert "local" in result.output
- assert "importable" in result.output
- finally:
- cache_module.LEANUP_CACHE_DIR = original_cache_root
-
-
-def test_setup_symlink_imports_reference_cache_when_available(tmp_path):
- class FakeElanManager:
- def is_elan_installed(self):
- return True
-
- def install_elan(self):
- return True
-
- def install_lean(self, version):
- return True
-
- def fake_lake_init(self, name, template=None):
- project_dir = self.cwd / name
- project_dir.mkdir(parents=True)
- (project_dir / "lakefile.lean").write_text(f"template={template}\n", encoding="utf-8")
- return "", "", 0
-
- def fake_lake_update(self):
- packages_dir = self.cwd / ".lake" / "packages"
- packages_dir.mkdir(parents=True, exist_ok=True)
- (self.cwd / "lake-manifest.json").write_text("{}\n", encoding="utf-8")
- return "", "", 0
-
- def fake_lake_build(self):
- return "", "", 0
-
- from leanup.repo import mathlib_cache as cache_module
- from leanup.repo.manager import LeanRepo
- from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
-
- source_dir = tmp_path / "reference-cache"
- _write_reference_archive(source_dir, "v4.22.0")
-
- original_cache_root = cache_module.LEANUP_CACHE_DIR
- cache_module.LEANUP_CACHE_DIR = tmp_path / "leanup-cache"
- original_source = cache_module.os.getenv("LEANUP_MATHLIB_CACHE_SOURCE")
- original_lake_init = LeanRepo.lake_init
- original_lake_update = LeanRepo.lake_update
- original_lake_build = LeanRepo.lake_build
-
- try:
- cache_module.os.environ["LEANUP_MATHLIB_CACHE_SOURCE"] = str(source_dir)
- LeanRepo.lake_init = fake_lake_init
- LeanRepo.lake_update = fake_lake_update
- LeanRepo.lake_build = fake_lake_build
-
- manager = LeanProjectSetup(elan_manager=FakeElanManager())
- manager.cache_manager = MathlibCacheManager()
-
- target = tmp_path / "ImportedDemo"
- config = SetupConfig(target_dir=target, lean_version="v4.22.0", dependency_mode="symlink")
-
- result = manager.setup(config)
-
- packages_link = target / ".lake" / "packages"
- assert result.used_cache is True
- assert packages_link.is_symlink()
- assert packages_link.resolve() == config.mathlib_cache_dir
- assert (packages_link / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cache for v4.22.0"
- finally:
- if original_source is None:
- cache_module.os.environ.pop("LEANUP_MATHLIB_CACHE_SOURCE", None)
- else:
- cache_module.os.environ["LEANUP_MATHLIB_CACHE_SOURCE"] = original_source
- cache_module.LEANUP_CACHE_DIR = original_cache_root
- LeanRepo.lake_init = original_lake_init
- LeanRepo.lake_update = original_lake_update
- LeanRepo.lake_build = original_lake_build
diff --git a/tests/test_mathlib_cache_cli.py b/tests/test_mathlib_cache_cli.py
new file mode 100644
index 0000000..6165dbc
--- /dev/null
+++ b/tests/test_mathlib_cache_cli.py
@@ -0,0 +1,37 @@
+from pathlib import Path
+import tarfile
+
+from click.testing import CliRunner
+
+from leanup.cli import cli
+
+
+def test_mathlib_cache_pack_archives_current_repo(tmp_path):
+ runner = CliRunner()
+ repo_dir = tmp_path / "Demo"
+ packages_dir = repo_dir / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached\n", encoding="utf-8")
+ output_dir = tmp_path / "cache"
+
+ result = runner.invoke(
+ cli,
+ [
+ "mathlib",
+ "cache",
+ "pack",
+ "--repo-dir",
+ str(repo_dir),
+ "--output-dir",
+ str(output_dir),
+ "--lean-version",
+ "v4.22.0",
+ ],
+ )
+
+ archive = output_dir / "v4.22.0" / "packages.tar.gz"
+ assert result.exit_code == 0
+ assert archive.exists()
+ with tarfile.open(archive, "r:gz") as tar:
+ names = tar.getnames()
+ assert "packages/mathlib/README.md" in names
diff --git a/tests/test_setup.py b/tests/test_setup.py
index 2c9df46..0ad224a 100644
--- a/tests/test_setup.py
+++ b/tests/test_setup.py
@@ -1,3 +1,4 @@
+import os
from pathlib import Path
from click.testing import CliRunner
@@ -57,7 +58,7 @@ def fake_setup(self, config):
assert captured["target_dir"] == target.resolve()
assert captured["lean_version"] == "v4.27.0"
assert captured["mathlib"] is True
- assert captured["dependency_mode"] == "build"
+ assert captured["dependency_mode"] == "symlink"
def test_setup_interactive_uses_previewed_dependency_mode(monkeypatch, tmp_path):
@@ -91,7 +92,7 @@ def fake_setup(self, config):
str(tmp_path / "Demo"),
"v4.27.0",
"Demo",
- "build",
+ "symlink",
])
confirmations = iter([True, False])
monkeypatch.setattr(setup_cli_module, "ask_text", lambda message, default="": next(values))
@@ -101,7 +102,7 @@ def fake_setup(self, config):
result = runner.invoke(cli, ["setup"])
assert result.exit_code == 0
- assert captured["dependency_mode"] == "build"
+ assert captured["dependency_mode"] == "symlink"
def test_setup_config_prefers_symlink_when_cache_exists(tmp_path):
@@ -113,7 +114,7 @@ def test_setup_config_prefers_symlink_when_cache_exists(tmp_path):
try:
config = SetupConfig(target_dir=tmp_path / "Demo", lean_version="v4.27.0")
- assert config.resolved_dependency_mode == "build"
+ assert config.resolved_dependency_mode == "symlink"
MathlibCacheManager().get_local_packages_dir("v4.27.0").mkdir(parents=True, exist_ok=True)
assert config.resolved_dependency_mode == "symlink"
@@ -150,6 +151,18 @@ def fake_lake_build(self):
build_dir.mkdir(parents=True, exist_ok=True)
return "", "", 0
+ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
+ assert Path(filepath).suffix == ".lean"
+ return "4.27.0\n", "", 0
+
+ def fake_lake(self, args):
+ if args == ["exe", "cache", "get"]:
+ packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached from cache get\n", encoding="utf-8")
+ return "", "", 0
+ raise AssertionError(f"unexpected lake args: {args}")
+
from leanup.repo import mathlib_cache as cache_module
original_cache_dir = cache_module.LEANUP_CACHE_DIR
@@ -161,9 +174,13 @@ def fake_lake_build(self):
original_lake_init = LeanRepo.lake_init
original_lake_update = LeanRepo.lake_update
original_lake_build = LeanRepo.lake_build
+ original_lake_env_lean = LeanRepo.lake_env_lean
+ original_lake = LeanRepo.lake
LeanRepo.lake_init = fake_lake_init
LeanRepo.lake_update = fake_lake_update
LeanRepo.lake_build = fake_lake_build
+ LeanRepo.lake_env_lean = fake_lake_env_lean
+ LeanRepo.lake = fake_lake
manager = LeanProjectSetup(elan_manager=FakeElanManager())
target = tmp_path / "BuildDemo"
@@ -177,6 +194,9 @@ def fake_lake_build(self):
assert result.used_cache is False
assert (target / "lean-toolchain").read_text(encoding="utf-8").strip() == "leanprover/lean4:v4.27.0"
+ assert (target / "lakefile.lean").read_text(encoding="utf-8").find('require mathlib from git') >= 0
+ assert (target / "BuildDemo.lean").exists()
+ assert (target / "BuildDemo" / "Basic.lean").exists()
assert config.mathlib_cache_dir.exists()
assert (config.mathlib_cache_dir / "mathlib" / "README.md").exists()
finally:
@@ -184,6 +204,8 @@ def fake_lake_build(self):
LeanRepo.lake_init = original_lake_init
LeanRepo.lake_update = original_lake_update
LeanRepo.lake_build = original_lake_build
+ LeanRepo.lake_env_lean = original_lake_env_lean
+ LeanRepo.lake = original_lake
def test_setup_symlink_mode_reuses_shared_cache(tmp_path):
@@ -212,6 +234,13 @@ def fake_lake_update(self):
def fake_lake_build(self):
return "", "", 0
+ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
+ assert Path(filepath).suffix == ".lean"
+ return "4.27.0\n", "", 0
+
+ def fake_lake(self, args):
+ raise AssertionError(f"unexpected lake args: {args}")
+
from leanup.repo import mathlib_cache as cache_module
original_cache_dir = cache_module.LEANUP_CACHE_DIR
@@ -223,9 +252,13 @@ def fake_lake_build(self):
original_lake_init = LeanRepo.lake_init
original_lake_update = LeanRepo.lake_update
original_lake_build = LeanRepo.lake_build
+ original_lake_env_lean = LeanRepo.lake_env_lean
+ original_lake = LeanRepo.lake
LeanRepo.lake_init = fake_lake_init
LeanRepo.lake_update = fake_lake_update
LeanRepo.lake_build = fake_lake_build
+ LeanRepo.lake_env_lean = fake_lake_env_lean
+ LeanRepo.lake = fake_lake
cached_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.27.0" / "packages" / "mathlib"
cached_packages.mkdir(parents=True, exist_ok=True)
@@ -247,3 +280,147 @@ def fake_lake_build(self):
LeanRepo.lake_init = original_lake_init
LeanRepo.lake_update = original_lake_update
LeanRepo.lake_build = original_lake_build
+ LeanRepo.lake_env_lean = original_lake_env_lean
+ LeanRepo.lake = original_lake
+
+
+def test_setup_symlink_mode_skips_lake_update_when_manifest_exists(tmp_path):
+ class FakeElanManager:
+ def is_elan_installed(self):
+ return True
+
+ def install_elan(self):
+ return True
+
+ def install_lean(self, version):
+ return True
+
+ calls = {"lake_update": 0, "lake_build": 0}
+
+ def fake_lake_update(self):
+ calls["lake_update"] += 1
+ return "", "", 0
+
+ def fake_lake_build(self):
+ calls["lake_build"] += 1
+ return "", "", 0
+
+ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
+ assert Path(filepath).suffix == ".lean"
+ return "4.22.0\n", "", 0
+
+ from leanup.repo import mathlib_cache as cache_module
+ from leanup.repo.manager import LeanRepo
+
+ original_cache_dir = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+ original_lake_update = LeanRepo.lake_update
+ original_lake_build = LeanRepo.lake_build
+ original_lake_env_lean = LeanRepo.lake_env_lean
+
+ try:
+ cached_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages" / "mathlib"
+ cached_packages.mkdir(parents=True, exist_ok=True)
+ (cached_packages / "README.md").write_text("cached\n", encoding="utf-8")
+
+ LeanRepo.lake_update = fake_lake_update
+ LeanRepo.lake_build = fake_lake_build
+ LeanRepo.lake_env_lean = fake_lake_env_lean
+
+ manager = LeanProjectSetup(elan_manager=FakeElanManager())
+ target = tmp_path / "FastDemo"
+ config = SetupConfig(target_dir=target, lean_version="v4.22.0", dependency_mode="symlink")
+
+ manager.setup(config)
+
+ assert calls["lake_update"] == 0
+ assert calls["lake_build"] == 1
+ assert (target / "lake-manifest.json").exists()
+ assert '"name": "FastDemo"' in (target / "lake-manifest.json").read_text(encoding="utf-8")
+ finally:
+ cache_module.LEANUP_CACHE_DIR = original_cache_dir
+ LeanRepo.lake_update = original_lake_update
+ LeanRepo.lake_build = original_lake_build
+ LeanRepo.lake_env_lean = original_lake_env_lean
+
+
+def test_setup_uses_bundled_manifest_without_external_reference(tmp_path):
+ class FakeElanManager:
+ def is_elan_installed(self):
+ return True
+
+ def install_elan(self):
+ return True
+
+ def install_lean(self, version):
+ return True
+
+ calls = {"lake_update": 0, "lake_build": 0}
+
+ def fake_lake_update(self):
+ calls["lake_update"] += 1
+ return "", "", 0
+
+ def fake_lake_build(self):
+ calls["lake_build"] += 1
+ return "", "", 0
+
+ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
+ assert Path(filepath).suffix == ".lean"
+ return "4.22.0\n", "", 0
+
+ def fake_lake(self, args):
+ if args == ["exe", "cache", "get"]:
+ packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached from cache get\n", encoding="utf-8")
+ return "", "", 0
+ raise AssertionError(f"unexpected lake args: {args}")
+
+ from leanup.repo import mathlib_cache as cache_module
+ from leanup.repo.manager import LeanRepo
+
+ original_cache_dir = cache_module.LEANUP_CACHE_DIR
+ cache_module.LEANUP_CACHE_DIR = tmp_path / "cache"
+ original_lake_update = LeanRepo.lake_update
+ original_lake_build = LeanRepo.lake_build
+ original_lake_env_lean = LeanRepo.lake_env_lean
+ original_lake = LeanRepo.lake
+
+ try:
+ LeanRepo.lake_update = fake_lake_update
+ LeanRepo.lake_build = fake_lake_build
+ LeanRepo.lake_env_lean = fake_lake_env_lean
+ LeanRepo.lake = fake_lake
+
+ manager = LeanProjectSetup(elan_manager=FakeElanManager())
+ target = tmp_path / "BundledDemo"
+ config = SetupConfig(target_dir=target, lean_version="v4.22.0", dependency_mode="symlink")
+
+ manager.setup(config)
+
+ assert calls["lake_update"] == 1
+ assert calls["lake_build"] == 1
+ assert (target / "lake-manifest.json").exists()
+ assert '"inputRev": "v4.22.0"' in (target / "lake-manifest.json").read_text(encoding="utf-8")
+ assert '"name": "BundledDemo"' in (target / "lake-manifest.json").read_text(encoding="utf-8")
+ assert (cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages").exists()
+ assert (
+ cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages" / "mathlib" / "README.md"
+ ).read_text(encoding="utf-8").strip() == "cached from cache get"
+ finally:
+ cache_module.LEANUP_CACHE_DIR = original_cache_dir
+ LeanRepo.lake_update = original_lake_update
+ LeanRepo.lake_build = original_lake_build
+ LeanRepo.lake_env_lean = original_lake_env_lean
+ LeanRepo.lake = original_lake
+
+
+def test_bundled_manifest_versions_cover_supported_range_subset():
+ from pathlib import Path
+
+ manifest_root = Path(__file__).resolve().parents[1] / "leanup" / "templates" / "mathlib" / "manifests"
+ bundled_versions = sorted(path.name for path in manifest_root.iterdir() if path.is_dir())
+
+ for version in ["v4.14.0", "v4.17.0", "v4.19.0", "v4.21.0", "v4.22.0"]:
+ assert version in bundled_versions
From de88c05b22a2621c324ea506271d43bb33861f80 Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Wed, 8 Apr 2026 00:32:06 +0800
Subject: [PATCH 6/8] feat: generate manifests from packages and polish cache
tools
---
README.md | 6 +
docs/getting-started/quickstart.md | 6 +
docs/index.md | 1 +
leanup/__init__.py | 2 +-
leanup/cli/mathlib.py | 9 +-
leanup/cli/setup.py | 17 ++-
leanup/repo/mathlib_cache.py | 28 +++-
leanup/repo/project_setup.py | 133 +++++++++++++++---
.../mathlib/{v4.xx.0 => }/Basic.lean.tmpl | 0
.../mathlib/{v4.xx.0 => }/README.md.tmpl | 0
.../mathlib/{v4.xx.0 => }/lakefile.lean.tmpl | 0
.../manifests/v4.14.0/lake-manifest.json | 95 -------------
.../manifests/v4.17.0/lake-manifest.json | 95 -------------
.../manifests/v4.19.0/lake-manifest.json | 95 -------------
.../manifests/v4.21.0/lake-manifest.json | 95 -------------
.../manifests/v4.22.0/lake-manifest.json | 95 -------------
.../manifests/v4.27.0/lake-manifest.json | 95 -------------
.../mathlib/{v4.xx.0 => }/root.lean.tmpl | 0
tests/test_mathlib_cache_cli.py | 77 ++++++++++
tests/test_setup.py | 98 ++++++++++---
20 files changed, 330 insertions(+), 617 deletions(-)
rename leanup/templates/mathlib/{v4.xx.0 => }/Basic.lean.tmpl (100%)
rename leanup/templates/mathlib/{v4.xx.0 => }/README.md.tmpl (100%)
rename leanup/templates/mathlib/{v4.xx.0 => }/lakefile.lean.tmpl (100%)
delete mode 100644 leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
delete mode 100644 leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
delete mode 100644 leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
delete mode 100644 leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
delete mode 100644 leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
delete mode 100644 leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
rename leanup/templates/mathlib/{v4.xx.0 => }/root.lean.tmpl (100%)
diff --git a/README.md b/README.md
index 22b16a6..9a0383a 100644
--- a/README.md
+++ b/README.md
@@ -126,8 +126,14 @@ leanup mathlib cache list
# 进入一个 Lean 仓库后,把当前仓库的 .lake/packages 打包到指定目录
cd /path/to/repo
leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
+
+# 如果本机安装了 pigz,也可以显式启用并发压缩
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --pigz
```
+- `--pigz` 会在本机存在 `pigz` 时启用并发压缩
+- 如果系统里没有 `pigz`,命令会自动回退到普通 gzip 打包
+
### 交互式安装
使用 `leanup repo install -i` 时,您可以配置:
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 72bd6ff..63ac34e 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -54,8 +54,14 @@ leanup mathlib cache list
# 进入当前仓库后,打包本仓库的 .lake/packages 到指定目录
cd /path/to/repo
leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
+
+# 如果本机安装了 pigz,也可以显式启用并发压缩
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --pigz
```
+- `--pigz` 会在本机存在 `pigz` 时启用并发压缩
+- 如果系统里没有 `pigz`,命令会自动回退到普通 gzip 打包
+
### 仓库管理
```bash
diff --git a/docs/index.md b/docs/index.md
index 5a6d87c..bbf4679 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -6,6 +6,7 @@
- `leanup setup`:快速创建固定 Lean 版本项目,支持 mathlib 共享缓存
- `leanup mathlib cache list`:查看本地 mathlib 共享缓存版本
+- `leanup mathlib cache pack`:将当前仓库的 `.lake/packages` 打包为共享缓存归档
- `leanup repo install`:安装 Lean 仓库,支持命令优先、交互补参
- `leanup repo list`:查看已安装仓库
diff --git a/leanup/__init__.py b/leanup/__init__.py
index c88e01c..f75a4bc 100644
--- a/leanup/__init__.py
+++ b/leanup/__init__.py
@@ -2,7 +2,7 @@
__author__ = """Lean-zh Community"""
__email__ = 'leanprover@outlook.com'
-__version__ = '0.1.9'
+__version__ = '0.2.0'
from .repo import (
RepoManager,
diff --git a/leanup/cli/mathlib.py b/leanup/cli/mathlib.py
index 121dede..9c33278 100644
--- a/leanup/cli/mathlib.py
+++ b/leanup/cli/mathlib.py
@@ -49,7 +49,12 @@ def list_cache() -> None:
required=True,
help="Lean version like v4.22.0.",
)
-def pack_cache(repo_dir: Path, output_dir: Path, lean_version: str) -> None:
+@click.option(
+ "--pigz/--no-pigz",
+ default=False,
+ help="Use pigz for parallel compression when it is available.",
+)
+def pack_cache(repo_dir: Path, output_dir: Path, lean_version: str, pigz: bool) -> None:
"""Pack current repository .lake/packages into a versioned cache archive."""
manager = MathlibCacheManager()
version = normalize_lean_version(lean_version)
@@ -57,7 +62,7 @@ def pack_cache(repo_dir: Path, output_dir: Path, lean_version: str) -> None:
archive = output_dir / version / "packages.tar.gz"
try:
- packed = manager.pack_packages_archive(packages_dir, archive)
+ packed = manager.pack_packages_archive(packages_dir, archive, use_pigz=pigz)
except ValueError as exc:
raise click.ClickException(str(exc)) from exc
diff --git a/leanup/cli/setup.py b/leanup/cli/setup.py
index cf3a616..fe4ffa7 100644
--- a/leanup/cli/setup.py
+++ b/leanup/cli/setup.py
@@ -14,6 +14,13 @@
from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
+def _require_non_empty(value: str, field_name: str) -> str:
+ cleaned = value.strip()
+ if not cleaned:
+ raise click.ClickException(f"{field_name} is required.")
+ return cleaned
+
+
@click.command(name="setup")
@click.argument("path", required=False, type=click.Path(path_type=Path))
@click.option(
@@ -73,9 +80,15 @@ def setup_project(
raise click.ClickException("Missing required setup path or Lean version.")
if need_prompt:
- path_input = ask_text("Project directory", default=str(path) if path else "")
+ path_input = _require_non_empty(
+ ask_text("Project directory", default=str(path) if path else ""),
+ "Project directory",
+ )
path = Path(path_input)
- lean_version = ask_text("Lean version", default=lean_version or "")
+ lean_version = _require_non_empty(
+ ask_text("Lean version", default=lean_version or ""),
+ "Lean version",
+ )
name = ask_text("Lake project name", default=name or path.name) or None
mathlib = ask_confirm("Enable mathlib dependency?", default=mathlib)
diff --git a/leanup/repo/mathlib_cache.py b/leanup/repo/mathlib_cache.py
index 390991d..9723187 100644
--- a/leanup/repo/mathlib_cache.py
+++ b/leanup/repo/mathlib_cache.py
@@ -4,7 +4,9 @@
from pathlib import Path
import re
import shutil
+import subprocess
import tarfile
+import tempfile
from leanup.const import LEANUP_CACHE_DIR
from leanup.utils.custom_logger import setup_logger
@@ -87,16 +89,34 @@ def refresh_local_cache(self, version: str, source_dir: Path, force: bool = True
logger.info(f"Refreshed mathlib cache {normalized} from {source_dir}")
return packages_dir
- def pack_packages_archive(self, packages_dir: Path, output_file: Path) -> Path:
+ def pack_packages_archive(self, packages_dir: Path, output_file: Path, use_pigz: bool = False) -> Path:
if not packages_dir.exists() or not packages_dir.is_dir():
raise ValueError(f"Packages directory not found: {packages_dir}")
+ source_dir = packages_dir.resolve() if packages_dir.is_symlink() else packages_dir
+
output_file.parent.mkdir(parents=True, exist_ok=True)
if output_file.exists():
output_file.unlink()
- with tarfile.open(output_file, "w:gz", dereference=False) as tar:
- tar.add(packages_dir, arcname="packages", recursive=True)
+ if use_pigz and shutil.which("pigz"):
+ self._pack_with_pigz(source_dir, output_file)
+ else:
+ with tarfile.open(output_file, "w:gz", dereference=False) as tar:
+ tar.add(source_dir, arcname="packages", recursive=True)
- logger.info(f"Packed {packages_dir} -> {output_file}")
+ logger.info(f"Packed {source_dir} -> {output_file}")
return output_file
+
+ def _pack_with_pigz(self, source_dir: Path, output_file: Path) -> None:
+ with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as handle:
+ temp_tar = Path(handle.name)
+
+ try:
+ with tarfile.open(temp_tar, "w", dereference=False) as tar:
+ tar.add(source_dir, arcname="packages", recursive=True)
+
+ with output_file.open("wb") as output_handle:
+ subprocess.run(["pigz", "-c", str(temp_tar)], check=True, stdout=output_handle)
+ finally:
+ temp_tar.unlink(missing_ok=True)
diff --git a/leanup/repo/project_setup.py b/leanup/repo/project_setup.py
index 1cd2ba1..9d17a17 100644
--- a/leanup/repo/project_setup.py
+++ b/leanup/repo/project_setup.py
@@ -6,6 +6,7 @@
import re
import shutil
import tempfile
+from urllib.parse import urlparse
from leanup.repo.elan import ElanManager
from leanup.repo.mathlib_cache import MathlibCacheManager, normalize_lean_version, remove_path
@@ -14,8 +15,7 @@
from leanup.utils.custom_logger import setup_logger
logger = setup_logger("project_setup")
-TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "templates" / "mathlib" / "v4.xx.0"
-BUNDLED_MANIFEST_ROOT = Path(__file__).resolve().parent.parent / "templates" / "mathlib" / "manifests"
+TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "templates" / "mathlib"
def sanitize_project_name(name: str) -> str:
@@ -86,31 +86,43 @@ def __init__(self, elan_manager: ElanManager | None = None):
def setup(self, config: SetupConfig) -> SetupResult:
config.validate()
+ logger.info(f"Preparing Lean project at {config.target_dir}")
self._ensure_target_available(config)
self._ensure_toolchain(config.lean_version)
with working_directory() as temp_dir:
project_dir = temp_dir / config.project_name
+ logger.info(f"Generating project skeleton for {config.project_name}")
self._create_project_skeleton(config, project_dir)
project = LeanRepo(project_dir)
self._write_toolchain(project_dir, config.toolchain)
- self._copy_reference_manifest(config, project_dir)
used_cache = False
cache_dir = config.mathlib_cache_dir if config.mathlib else None
if config.mathlib and config.resolved_dependency_mode == "symlink":
+ logger.info("Checking reusable mathlib package cache")
used_cache = self._prepare_mathlib_cache(config, project_dir)
+ if used_cache:
+ self._write_manifest_from_packages(config, project_dir)
if config.mathlib and self._should_run_lake_update(config, project_dir):
+ logger.info("Running lake update")
self._run_lake_update(project)
+ logger.info("Running lake exe cache get")
self._run_lake_cache_get(project)
+ logger.info("Refreshing shared packages cache")
self._refresh_mathlib_cache(config, project_dir)
+ logger.info("Generating manifest from resolved packages")
+ self._write_manifest_from_packages(config, project_dir)
if config.resolved_dependency_mode == "symlink":
+ logger.info("Linking shared packages cache into project")
self._link_mathlib_cache(config, project_dir)
used_cache = True
+ logger.info("Running lake build")
self._run_lake_build(project)
+ logger.info("Verifying project with Mathlib.Init and Lean.versionString")
self._verify_mathlib_project(project_dir)
shutil.move(str(project_dir), str(config.target_dir))
@@ -134,6 +146,7 @@ def _ensure_target_available(self, config: SetupConfig) -> None:
target.parent.mkdir(parents=True, exist_ok=True)
def _ensure_toolchain(self, version: str) -> None:
+ logger.info(f"Ensuring Lean toolchain {version} is installed")
if not self.elan_manager.is_elan_installed() and not self.elan_manager.install_elan():
raise RuntimeError("Failed to install elan.")
if not self.elan_manager.install_lean(version):
@@ -166,26 +179,106 @@ def _render_mathlib_template(self, config: SetupConfig, project_dir: Path) -> No
output_path.parent.mkdir(parents=True, exist_ok=True)
output_path.write_text(content.format(**context), encoding="utf-8")
- def _bundled_manifest_path(self, version: str) -> Path | None:
- candidate = BUNDLED_MANIFEST_ROOT / normalize_lean_version(version) / "lake-manifest.json"
- if candidate.exists():
- return candidate
- return None
-
- def _copy_reference_manifest(self, config: SetupConfig, project_dir: Path) -> None:
- if not config.mathlib:
+ def _write_manifest_from_packages(self, config: SetupConfig, project_dir: Path) -> None:
+ packages_dir = project_dir / ".lake" / "packages"
+ if not packages_dir.exists():
return
- bundled_manifest = self._bundled_manifest_path(config.lean_version)
- if bundled_manifest:
- manifest_path = project_dir / "lake-manifest.json"
- shutil.copy2(bundled_manifest, manifest_path)
- self._rewrite_manifest_name(manifest_path, config.project_name)
-
- def _rewrite_manifest_name(self, manifest_path: Path, project_name: str) -> None:
- manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
- manifest["name"] = project_name
+ if not self._can_generate_manifest_from_packages(packages_dir):
+ logger.info("Skipping manifest generation from packages because git metadata is incomplete")
+ return
+
+ mathlib_manifest = self._read_mathlib_embedded_manifest(packages_dir)
+ packages = []
+ for package_dir in sorted(path for path in packages_dir.iterdir() if path.is_dir()):
+ packages.append(
+ self._build_manifest_entry(
+ package_dir=package_dir,
+ lean_version=config.lean_version,
+ mathlib_manifest=mathlib_manifest,
+ )
+ )
+
+ manifest = {
+ "version": "1.1.0",
+ "packagesDir": ".lake/packages",
+ "packages": packages,
+ "name": config.project_name,
+ "lakeDir": ".lake",
+ }
+ manifest_path = project_dir / "lake-manifest.json"
manifest_path.write_text(json.dumps(manifest, indent=1) + "\n", encoding="utf-8")
+ def _can_generate_manifest_from_packages(self, packages_dir: Path) -> bool:
+ package_dirs = [path for path in packages_dir.iterdir() if path.is_dir()]
+ if not package_dirs:
+ return False
+ for package_dir in package_dirs:
+ if not (package_dir / ".git").exists():
+ return False
+ return True
+
+ def _read_mathlib_embedded_manifest(self, packages_dir: Path) -> dict[str, dict]:
+ mathlib_manifest_path = packages_dir / "mathlib" / "lake-manifest.json"
+ if not mathlib_manifest_path.exists():
+ return {}
+ manifest = json.loads(mathlib_manifest_path.read_text(encoding="utf-8"))
+ return {entry["name"]: entry for entry in manifest.get("packages", [])}
+
+ def _build_manifest_entry(
+ self,
+ package_dir: Path,
+ lean_version: str,
+ mathlib_manifest: dict[str, dict],
+ ) -> dict:
+ package_name = package_dir.name
+ url = self._read_git_origin_url(package_dir)
+ head_rev = self._read_git_head(package_dir)
+ config_file = self._detect_config_file(package_dir)
+ inherited = package_name != "mathlib"
+ input_rev = lean_version if package_name == "mathlib" else mathlib_manifest.get(package_name, {}).get("inputRev", "main")
+
+ return {
+ "url": url,
+ "type": "git",
+ "subDir": None,
+ "scope": self._infer_scope(url),
+ "rev": head_rev,
+ "name": package_name,
+ "manifestFile": "lake-manifest.json",
+ "inputRev": input_rev,
+ "inherited": inherited,
+ "configFile": config_file,
+ }
+
+ def _read_git_origin_url(self, package_dir: Path) -> str:
+ from git import Repo
+
+ repo = Repo(package_dir)
+ return next(repo.remote("origin").urls)
+
+ def _read_git_head(self, package_dir: Path) -> str:
+ from git import Repo
+
+ repo = Repo(package_dir)
+ return repo.head.commit.hexsha
+
+ def _detect_config_file(self, package_dir: Path) -> str:
+ if (package_dir / "lakefile.lean").exists():
+ return "lakefile.lean"
+ if (package_dir / "lakefile.toml").exists():
+ return "lakefile.toml"
+ raise RuntimeError(f"No Lake config file found in package: {package_dir}")
+
+ def _infer_scope(self, url: str) -> str:
+ parsed = urlparse(url)
+ path = parsed.path.rstrip("/")
+ if path.endswith(".git"):
+ path = path[:-4]
+ parts = [part for part in path.split("/") if part]
+ if len(parts) >= 2:
+ return parts[-2]
+ return ""
+
def _should_run_lake_update(self, config: SetupConfig, project_dir: Path) -> bool:
if not config.mathlib:
return False
diff --git a/leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl b/leanup/templates/mathlib/Basic.lean.tmpl
similarity index 100%
rename from leanup/templates/mathlib/v4.xx.0/Basic.lean.tmpl
rename to leanup/templates/mathlib/Basic.lean.tmpl
diff --git a/leanup/templates/mathlib/v4.xx.0/README.md.tmpl b/leanup/templates/mathlib/README.md.tmpl
similarity index 100%
rename from leanup/templates/mathlib/v4.xx.0/README.md.tmpl
rename to leanup/templates/mathlib/README.md.tmpl
diff --git a/leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl b/leanup/templates/mathlib/lakefile.lean.tmpl
similarity index 100%
rename from leanup/templates/mathlib/v4.xx.0/lakefile.lean.tmpl
rename to leanup/templates/mathlib/lakefile.lean.tmpl
diff --git a/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
deleted file mode 100644
index efa9181..0000000
--- a/leanup/templates/mathlib/manifests/v4.14.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "4bbdccd9c5f862bf90ff12f0a9e2c8be032b9a84",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.14.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "42dc02bdbc5d0c2f395718462a76c3d87318f7fa",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "d7caecce0d0f003fd5e9cce9a61f1dd6ba83142b",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "519e509a28864af5bed98033dd33b95cf08e9aa7",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.14.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "68280daef58803f68368eb2e53046dabcd270c9d",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.47",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "5a0ec8588855265ade536f35bcdcf0fb24fd6030",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.14.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "303b23fbcea94ac4f96e590c1cad6618fd4f5f41",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "8d6c853f11a5172efa0e96b9f2be1a83d861cdd9",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.14.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "726b3c9ad13acca724d4651f14afc4804a7b0e4d",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
deleted file mode 100644
index 4c9e941..0000000
--- a/leanup/templates/mathlib/manifests/v4.17.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "5269898d6a51d047931107c8d72d934d8d5d3753",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.17.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "c708be04267e3e995a14ac0d08b1530579c1525a",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "0c169a0d55fef3763cfb3099eafd7b884ec7e41d",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "0447b0a7b7f41f0a1749010db3f222e4a96f9d30",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "799f6986de9f61b784ff7be8f6a8b101045b8ffd",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.52",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "56a2c80b209c253e0281ac4562a92122b457dcc0",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "95561f7a5811fae6a309e4a1bbe22a0a4a98bf03",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "efcc7d9bd9936ecdc625baf0d033b60866565cd5",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "e7fd1a415c80985ade02a021172834ca2139b0ca",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
deleted file mode 100644
index 231ea65..0000000
--- a/leanup/templates/mathlib/manifests/v4.19.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "c44e0c8ee63ca166450922a373c7409c5d26b00b",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.19.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "77e08eddc486491d7b9e470926b3dbe50319451a",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "25078369972d295301f5a1e53c3e5850cf6d9d4c",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "e6a9f0f5ee3ccf7443a0070f92b62f8db12ae82b",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "c4919189477c3221e6a204008998b0d724f49904",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.57",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "5d50b08dedd7d69b3d9b3176e0d58a23af228884",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "fa4f7f15d97591a9cf3aa7724ba371c7fc6dda02",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "f5d04a9c4973d401c8c92500711518f7c656f034",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "02dbd02bc00ec4916e99b04b2245b30200e200d0",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
deleted file mode 100644
index b1363cf..0000000
--- a/leanup/templates/mathlib/manifests/v4.21.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "308445d7985027f538e281e18df29ca16ede2ba3",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.21.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "c4aa78186d388e50a436e8362b947bae125a2933",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "6c62474116f525d2814f0157bb468bf3a4f9f120",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "d07bd64f1910f1cc5e4cc87b6b9c590080e7a457",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "6980f6ca164de593cb77cd03d8eac549cc444156",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.62",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "8ff27701d003456fd59f13a9212431239d902aef",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "e9c65db4823976353cd0bb03199a172719efbeb7",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "8d2067bf518731a70a255d4a61b5c103922c772e",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "7c6aef5f75a43ebbba763b44d535175a1b04c9e0",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
deleted file mode 100644
index 19c996c..0000000
--- a/leanup/templates/mathlib/manifests/v4.22.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "79e94a093aff4a60fb1b1f92d9681e407124c2ca",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "b100ad4c5d74a464f497aaa8e7c74d86bf39a56f",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "99657ad92e23804e279f77ea6dbdeebaa1317b98",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "eb164a46de87078f27640ee71e6c3841defc2484",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "1253a071e6939b0faf5c09d2b30b0bfc79dae407",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.68",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "1256a18522728c2eeed6109b02dd2b8f207a2a3c",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "917bfa5064b812b7fbd7112d018ea0b4def25ab3",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "240676e9568c254a69be94801889d4b13f3b249f",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.22.0",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "c682c91d2d4dd59a7187e2ab977ac25bd1f87329",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json b/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
deleted file mode 100644
index 6769981..0000000
--- a/leanup/templates/mathlib/manifests/v4.27.0/lake-manifest.json
+++ /dev/null
@@ -1,95 +0,0 @@
-{"version": "1.1.0",
- "packagesDir": ".lake/packages",
- "packages":
- [{"url": "https://github.com/leanprover-community/mathlib4.git",
- "type": "git",
- "subDir": null,
- "scope": "",
- "rev": "a3a10db0e9d66acbebf76c5e6a135066525ac900",
- "name": "mathlib",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.27.0",
- "inherited": false,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/plausible",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "009dc1e6f2feb2c96c081537d80a0905b2c6498f",
- "name": "plausible",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/LeanSearchClient",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "5ce7f0a355f522a952a3d678d696bd563bb4fd28",
- "name": "LeanSearchClient",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/import-graph",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "8f497d55985a189cea8020d9dc51260af1e41ad2",
- "name": "importGraph",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/ProofWidgets4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "c04225ee7c0585effbd933662b3151f01b600e40",
- "name": "proofwidgets",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v0.0.85",
- "inherited": true,
- "configFile": "lakefile.lean"},
- {"url": "https://github.com/leanprover-community/aesop",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "cb837cc26236ada03c81837bebe0acd9c70ced7d",
- "name": "aesop",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/quote4",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "bd58c9efe2086d56ca361807014141a860ddbf8c",
- "name": "Qq",
- "manifestFile": "lake-manifest.json",
- "inputRev": "master",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover-community/batteries",
- "type": "git",
- "subDir": null,
- "scope": "leanprover-community",
- "rev": "b25b36a7caf8e237e7d1e6121543078a06777c8a",
- "name": "batteries",
- "manifestFile": "lake-manifest.json",
- "inputRev": "main",
- "inherited": true,
- "configFile": "lakefile.toml"},
- {"url": "https://github.com/leanprover/lean4-cli",
- "type": "git",
- "subDir": null,
- "scope": "leanprover",
- "rev": "55c37290ff6186e2e965d68cf853a57c0702db82",
- "name": "Cli",
- "manifestFile": "lake-manifest.json",
- "inputRev": "v4.27.0",
- "inherited": true,
- "configFile": "lakefile.toml"}],
- "name": "lean4web",
- "lakeDir": ".lake"}
diff --git a/leanup/templates/mathlib/v4.xx.0/root.lean.tmpl b/leanup/templates/mathlib/root.lean.tmpl
similarity index 100%
rename from leanup/templates/mathlib/v4.xx.0/root.lean.tmpl
rename to leanup/templates/mathlib/root.lean.tmpl
diff --git a/tests/test_mathlib_cache_cli.py b/tests/test_mathlib_cache_cli.py
index 6165dbc..baa0a5e 100644
--- a/tests/test_mathlib_cache_cli.py
+++ b/tests/test_mathlib_cache_cli.py
@@ -4,6 +4,7 @@
from click.testing import CliRunner
from leanup.cli import cli
+from leanup.repo.mathlib_cache import MathlibCacheManager
def test_mathlib_cache_pack_archives_current_repo(tmp_path):
@@ -35,3 +36,79 @@ def test_mathlib_cache_pack_archives_current_repo(tmp_path):
with tarfile.open(archive, "r:gz") as tar:
names = tar.getnames()
assert "packages/mathlib/README.md" in names
+
+
+def test_mathlib_cache_pack_follows_root_packages_symlink(tmp_path):
+ runner = CliRunner()
+ source_dir = tmp_path / "shared-packages" / "mathlib"
+ source_dir.mkdir(parents=True, exist_ok=True)
+ (source_dir / "README.md").write_text("cached\n", encoding="utf-8")
+
+ repo_dir = tmp_path / "Demo"
+ lake_dir = repo_dir / ".lake"
+ lake_dir.mkdir(parents=True, exist_ok=True)
+ (lake_dir / "packages").symlink_to(source_dir.parent, target_is_directory=True)
+
+ output_dir = tmp_path / "cache"
+ result = runner.invoke(
+ cli,
+ [
+ "mathlib",
+ "cache",
+ "pack",
+ "--repo-dir",
+ str(repo_dir),
+ "--output-dir",
+ str(output_dir),
+ "--lean-version",
+ "v4.29.0",
+ ],
+ )
+
+ archive = output_dir / "v4.29.0" / "packages.tar.gz"
+ assert result.exit_code == 0
+ with tarfile.open(archive, "r:gz") as tar:
+ names = tar.getnames()
+ assert "packages/mathlib/README.md" in names
+
+
+def test_mathlib_cache_pack_passes_pigz_option(monkeypatch, tmp_path):
+ runner = CliRunner()
+ repo_dir = tmp_path / "Demo"
+ packages_dir = repo_dir / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached\n", encoding="utf-8")
+ output_dir = tmp_path / "cache"
+
+ captured = {}
+
+ def fake_pack(self, packages_dir, output_file, use_pigz=False):
+ captured["packages_dir"] = packages_dir
+ captured["output_file"] = output_file
+ captured["use_pigz"] = use_pigz
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ output_file.write_bytes(b"ok")
+ return output_file
+
+ monkeypatch.setattr(MathlibCacheManager, "pack_packages_archive", fake_pack)
+
+ result = runner.invoke(
+ cli,
+ [
+ "mathlib",
+ "cache",
+ "pack",
+ "--repo-dir",
+ str(repo_dir),
+ "--output-dir",
+ str(output_dir),
+ "--lean-version",
+ "v4.22.0",
+ "--pigz",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert captured["packages_dir"] == repo_dir / ".lake" / "packages"
+ assert captured["output_file"] == output_dir / "v4.22.0" / "packages.tar.gz"
+ assert captured["use_pigz"] is True
diff --git a/tests/test_setup.py b/tests/test_setup.py
index 0ad224a..b8aa82e 100644
--- a/tests/test_setup.py
+++ b/tests/test_setup.py
@@ -2,11 +2,26 @@
from pathlib import Path
from click.testing import CliRunner
+from git import Repo
from leanup.cli import cli
from leanup.repo.project_setup import LeanProjectSetup, SetupConfig
+def _init_fake_package_repo(package_dir: Path, package_name: str = "mathlib") -> None:
+ package_dir.mkdir(parents=True, exist_ok=True)
+ (package_dir / "README.md").write_text("cached\n", encoding="utf-8")
+ (package_dir / "lakefile.lean").write_text(f"package {package_name} where\n", encoding="utf-8")
+ repo = Repo.init(package_dir)
+ with repo.config_writer() as config:
+ config.set_value("user", "name", "LeanUp Test")
+ config.set_value("user", "email", "leanup@example.com")
+ if "origin" not in [remote.name for remote in repo.remotes]:
+ repo.create_remote("origin", f"https://github.com/leanprover-community/{package_name}.git")
+ repo.index.add(["README.md", "lakefile.lean"])
+ repo.index.commit("init")
+
+
def test_setup_command_rejects_symlink_without_mathlib():
runner = CliRunner()
@@ -105,6 +120,45 @@ def fake_setup(self, config):
assert captured["dependency_mode"] == "symlink"
+def test_setup_interactive_rejects_blank_project_directory(monkeypatch):
+ import importlib
+
+ setup_cli_module = importlib.import_module("leanup.cli.setup")
+ runner = CliRunner()
+
+ monkeypatch.setattr(
+ setup_cli_module,
+ "resolve_interactive_mode",
+ lambda interactive, auto_prompt_condition: (None, True, False, True, True),
+ )
+ monkeypatch.setattr(setup_cli_module, "ask_text", lambda message, default="": " ")
+
+ result = runner.invoke(cli, ["setup"])
+
+ assert result.exit_code != 0
+ assert "Project directory is required." in result.output
+
+
+def test_setup_interactive_rejects_blank_lean_version(monkeypatch, tmp_path):
+ import importlib
+
+ setup_cli_module = importlib.import_module("leanup.cli.setup")
+ runner = CliRunner()
+ values = iter([str(tmp_path / "Demo"), " "])
+
+ monkeypatch.setattr(
+ setup_cli_module,
+ "resolve_interactive_mode",
+ lambda interactive, auto_prompt_condition: (None, True, False, True, True),
+ )
+ monkeypatch.setattr(setup_cli_module, "ask_text", lambda message, default="": next(values))
+
+ result = runner.invoke(cli, ["setup"])
+
+ assert result.exit_code != 0
+ assert "Lean version is required." in result.output
+
+
def test_setup_config_prefers_symlink_when_cache_exists(tmp_path):
from leanup.repo.mathlib_cache import MathlibCacheManager
from leanup.repo import mathlib_cache as cache_module
@@ -158,8 +212,11 @@ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
def fake_lake(self, args):
if args == ["exe", "cache", "get"]:
packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
- packages_dir.mkdir(parents=True, exist_ok=True)
+ _init_fake_package_repo(packages_dir)
(packages_dir / "README.md").write_text("cached from cache get\n", encoding="utf-8")
+ repo = Repo(packages_dir)
+ repo.index.add(["README.md", "lakefile.lean"])
+ repo.index.commit("update readme")
return "", "", 0
raise AssertionError(f"unexpected lake args: {args}")
@@ -225,7 +282,10 @@ def fake_lake_init(self, name, template=None):
(project_dir / "lakefile.lean").write_text(f"template={template}\n", encoding="utf-8")
return "", "", 0
+ calls = {"lake_update": 0, "cache_get": 0}
+
def fake_lake_update(self):
+ calls["lake_update"] += 1
packages_dir = self.cwd / ".lake" / "packages"
packages_dir.mkdir(parents=True, exist_ok=True)
(self.cwd / "lake-manifest.json").write_text("{}\n", encoding="utf-8")
@@ -239,6 +299,12 @@ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
return "4.27.0\n", "", 0
def fake_lake(self, args):
+ if args == ["exe", "cache", "get"]:
+ calls["cache_get"] += 1
+ packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached\n", encoding="utf-8")
+ return "", "", 0
raise AssertionError(f"unexpected lake args: {args}")
from leanup.repo import mathlib_cache as cache_module
@@ -261,8 +327,7 @@ def fake_lake(self, args):
LeanRepo.lake = fake_lake
cached_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.27.0" / "packages" / "mathlib"
- cached_packages.mkdir(parents=True, exist_ok=True)
- (cached_packages / "README.md").write_text("cached\n", encoding="utf-8")
+ _init_fake_package_repo(cached_packages)
manager = LeanProjectSetup(elan_manager=FakeElanManager())
target = tmp_path / "SymlinkDemo"
@@ -275,6 +340,9 @@ def fake_lake(self, args):
assert packages_link.is_symlink()
assert packages_link.resolve() == config.mathlib_cache_dir
assert (packages_link / "mathlib" / "README.md").read_text(encoding="utf-8").strip() == "cached"
+ assert (target / "lake-manifest.json").exists()
+ assert calls["lake_update"] == 0
+ assert calls["cache_get"] == 0
finally:
cache_module.LEANUP_CACHE_DIR = original_cache_dir
LeanRepo.lake_init = original_lake_init
@@ -320,8 +388,7 @@ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
try:
cached_packages = cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages" / "mathlib"
- cached_packages.mkdir(parents=True, exist_ok=True)
- (cached_packages / "README.md").write_text("cached\n", encoding="utf-8")
+ _init_fake_package_repo(cached_packages)
LeanRepo.lake_update = fake_lake_update
LeanRepo.lake_build = fake_lake_build
@@ -372,8 +439,11 @@ def fake_lake_env_lean(self, filepath, json=True, options=None, nproc=None):
def fake_lake(self, args):
if args == ["exe", "cache", "get"]:
packages_dir = self.cwd / ".lake" / "packages" / "mathlib"
- packages_dir.mkdir(parents=True, exist_ok=True)
+ _init_fake_package_repo(packages_dir)
(packages_dir / "README.md").write_text("cached from cache get\n", encoding="utf-8")
+ repo = Repo(packages_dir)
+ repo.index.add(["README.md", "lakefile.lean"])
+ repo.index.commit("update readme")
return "", "", 0
raise AssertionError(f"unexpected lake args: {args}")
@@ -402,8 +472,10 @@ def fake_lake(self, args):
assert calls["lake_update"] == 1
assert calls["lake_build"] == 1
assert (target / "lake-manifest.json").exists()
- assert '"inputRev": "v4.22.0"' in (target / "lake-manifest.json").read_text(encoding="utf-8")
- assert '"name": "BundledDemo"' in (target / "lake-manifest.json").read_text(encoding="utf-8")
+ manifest_text = (target / "lake-manifest.json").read_text(encoding="utf-8")
+ assert '"inputRev": "v4.22.0"' in manifest_text
+ assert '"name": "BundledDemo"' in manifest_text
+ assert '"rev": "' in manifest_text
assert (cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages").exists()
assert (
cache_module.LEANUP_CACHE_DIR / "setup" / "mathlib" / "v4.22.0" / "packages" / "mathlib" / "README.md"
@@ -414,13 +486,3 @@ def fake_lake(self, args):
LeanRepo.lake_build = original_lake_build
LeanRepo.lake_env_lean = original_lake_env_lean
LeanRepo.lake = original_lake
-
-
-def test_bundled_manifest_versions_cover_supported_range_subset():
- from pathlib import Path
-
- manifest_root = Path(__file__).resolve().parents[1] / "leanup" / "templates" / "mathlib" / "manifests"
- bundled_versions = sorted(path.name for path in manifest_root.iterdir() if path.is_dir())
-
- for version in ["v4.14.0", "v4.17.0", "v4.19.0", "v4.21.0", "v4.22.0"]:
- assert version in bundled_versions
From c5bfdaaae1c313b2238e4e0ef37b6b9092468568 Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Wed, 8 Apr 2026 00:46:16 +0800
Subject: [PATCH 7/8] feat: improve cache packing defaults and progress logs
---
README.md | 7 ++--
docs/getting-started/quickstart.md | 7 ++--
leanup/cli/mathlib.py | 2 +-
leanup/repo/mathlib_cache.py | 55 ++++++++++++++++++++++--------
tests/test_mathlib_cache_cli.py | 37 ++++++++++++++++++++
5 files changed, 86 insertions(+), 22 deletions(-)
diff --git a/README.md b/README.md
index 9a0383a..51eb441 100644
--- a/README.md
+++ b/README.md
@@ -127,12 +127,13 @@ leanup mathlib cache list
cd /path/to/repo
leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
-# 如果本机安装了 pigz,也可以显式启用并发压缩
-leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --pigz
+# 如需关闭并发压缩,可以显式禁用 pigz
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --no-pigz
```
-- `--pigz` 会在本机存在 `pigz` 时启用并发压缩
+- 默认会在本机存在 `pigz` 时启用并发压缩
- 如果系统里没有 `pigz`,命令会自动回退到普通 gzip 打包
+- `--no-pigz` 可显式关闭并发压缩
### 交互式安装
diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md
index 63ac34e..a51dfe6 100644
--- a/docs/getting-started/quickstart.md
+++ b/docs/getting-started/quickstart.md
@@ -55,12 +55,13 @@ leanup mathlib cache list
cd /path/to/repo
leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache
-# 如果本机安装了 pigz,也可以显式启用并发压缩
-leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --pigz
+# 如需关闭并发压缩,可以显式禁用 pigz
+leanup mathlib cache pack --lean-version v4.22.0 --output-dir /path/to/cache --no-pigz
```
-- `--pigz` 会在本机存在 `pigz` 时启用并发压缩
+- 默认会在本机存在 `pigz` 时启用并发压缩
- 如果系统里没有 `pigz`,命令会自动回退到普通 gzip 打包
+- `--no-pigz` 可显式关闭并发压缩
### 仓库管理
diff --git a/leanup/cli/mathlib.py b/leanup/cli/mathlib.py
index 9c33278..75b0352 100644
--- a/leanup/cli/mathlib.py
+++ b/leanup/cli/mathlib.py
@@ -51,7 +51,7 @@ def list_cache() -> None:
)
@click.option(
"--pigz/--no-pigz",
- default=False,
+ default=True,
help="Use pigz for parallel compression when it is available.",
)
def pack_cache(repo_dir: Path, output_dir: Path, lean_version: str, pigz: bool) -> None:
diff --git a/leanup/repo/mathlib_cache.py b/leanup/repo/mathlib_cache.py
index 9723187..2983092 100644
--- a/leanup/repo/mathlib_cache.py
+++ b/leanup/repo/mathlib_cache.py
@@ -94,29 +94,54 @@ def pack_packages_archive(self, packages_dir: Path, output_file: Path, use_pigz:
raise ValueError(f"Packages directory not found: {packages_dir}")
source_dir = packages_dir.resolve() if packages_dir.is_symlink() else packages_dir
+ logger.info(f"Preparing packages archive from {packages_dir}")
+ if packages_dir.is_symlink():
+ logger.info(f"Resolved root packages symlink to {source_dir}")
output_file.parent.mkdir(parents=True, exist_ok=True)
- if output_file.exists():
- output_file.unlink()
+ temp_output = output_file.parent / f".{output_file.name}.tmp"
+ remove_path(temp_output)
if use_pigz and shutil.which("pigz"):
- self._pack_with_pigz(source_dir, output_file)
+ logger.info("Using pigz for parallel compression")
+ self._pack_with_pigz(source_dir, temp_output)
else:
- with tarfile.open(output_file, "w:gz", dereference=False) as tar:
+ if use_pigz:
+ logger.info("pigz requested but not found; falling back to standard gzip")
+ else:
+ logger.info("Using standard gzip compression")
+ logger.info(f"Writing tar.gz archive to temporary file {temp_output}")
+ with tarfile.open(temp_output, "w:gz", dereference=False) as tar:
tar.add(source_dir, arcname="packages", recursive=True)
+ remove_path(output_file)
+ temp_output.replace(output_file)
+
logger.info(f"Packed {source_dir} -> {output_file}")
return output_file
def _pack_with_pigz(self, source_dir: Path, output_file: Path) -> None:
- with tempfile.NamedTemporaryFile(suffix=".tar", delete=False) as handle:
- temp_tar = Path(handle.name)
-
- try:
- with tarfile.open(temp_tar, "w", dereference=False) as tar:
- tar.add(source_dir, arcname="packages", recursive=True)
-
- with output_file.open("wb") as output_handle:
- subprocess.run(["pigz", "-c", str(temp_tar)], check=True, stdout=output_handle)
- finally:
- temp_tar.unlink(missing_ok=True)
+ logger.info(f"Streaming tar archive from {source_dir.parent} into pigz")
+ transform = f"s,^{re.escape(source_dir.name)},packages,"
+ with output_file.open("wb") as output_handle:
+ tar_proc = subprocess.Popen(
+ [
+ "tar",
+ "-C",
+ str(source_dir.parent),
+ "--transform",
+ transform,
+ "-cf",
+ "-",
+ source_dir.name,
+ ],
+ stdout=subprocess.PIPE,
+ )
+ try:
+ subprocess.run(["pigz", "-c"], check=True, stdin=tar_proc.stdout, stdout=output_handle)
+ finally:
+ if tar_proc.stdout is not None:
+ tar_proc.stdout.close()
+ tar_returncode = tar_proc.wait()
+ if tar_returncode != 0:
+ raise subprocess.CalledProcessError(tar_returncode, tar_proc.args)
diff --git a/tests/test_mathlib_cache_cli.py b/tests/test_mathlib_cache_cli.py
index baa0a5e..a855393 100644
--- a/tests/test_mathlib_cache_cli.py
+++ b/tests/test_mathlib_cache_cli.py
@@ -112,3 +112,40 @@ def fake_pack(self, packages_dir, output_file, use_pigz=False):
assert captured["packages_dir"] == repo_dir / ".lake" / "packages"
assert captured["output_file"] == output_dir / "v4.22.0" / "packages.tar.gz"
assert captured["use_pigz"] is True
+
+
+def test_mathlib_cache_pack_uses_pigz_by_default(monkeypatch, tmp_path):
+ runner = CliRunner()
+ repo_dir = tmp_path / "Demo"
+ packages_dir = repo_dir / ".lake" / "packages" / "mathlib"
+ packages_dir.mkdir(parents=True, exist_ok=True)
+ (packages_dir / "README.md").write_text("cached\n", encoding="utf-8")
+ output_dir = tmp_path / "cache"
+
+ captured = {}
+
+ def fake_pack(self, packages_dir, output_file, use_pigz=False):
+ captured["use_pigz"] = use_pigz
+ output_file.parent.mkdir(parents=True, exist_ok=True)
+ output_file.write_bytes(b"ok")
+ return output_file
+
+ monkeypatch.setattr(MathlibCacheManager, "pack_packages_archive", fake_pack)
+
+ result = runner.invoke(
+ cli,
+ [
+ "mathlib",
+ "cache",
+ "pack",
+ "--repo-dir",
+ str(repo_dir),
+ "--output-dir",
+ str(output_dir),
+ "--lean-version",
+ "v4.22.0",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert captured["use_pigz"] is True
From 87ea772860912ccc2c83333aee114dd8b5d98dc5 Mon Sep 17 00:00:00 2001
From: rex <1073853456@qq.com>
Date: Wed, 8 Apr 2026 01:12:06 +0800
Subject: [PATCH 8/8] ci: drop windows test job
---
.github/workflows/ci.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml
index ad35aad..48759ae 100644
--- a/.github/workflows/ci.yaml
+++ b/.github/workflows/ci.yaml
@@ -16,7 +16,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- os: [ubuntu-latest, macos-latest, windows-latest]
+ os: [ubuntu-latest, macos-latest]
python-version: ["3.9"]
steps: