aristotlelib 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aristotlelib/__init__.py +10 -0
- aristotlelib/api_request.py +129 -0
- aristotlelib/local_file_utils.py +421 -0
- aristotlelib/project.py +371 -0
- aristotlelib-0.1.0.dist-info/METADATA +259 -0
- aristotlelib-0.1.0.dist-info/RECORD +9 -0
- aristotlelib-0.1.0.dist-info/WHEEL +5 -0
- aristotlelib-0.1.0.dist-info/licenses/LICENSE +15 -0
- aristotlelib-0.1.0.dist-info/top_level.txt +1 -0
aristotlelib/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from aristotlelib.api_request import set_api_key
|
|
2
|
+
|
|
3
|
+
from aristotlelib.project import Project, ProjectStatus
|
|
4
|
+
from aristotlelib.local_file_utils import (
|
|
5
|
+
find_lean_project_root,
|
|
6
|
+
validate_local_file_paths,
|
|
7
|
+
gather_file_imports,
|
|
8
|
+
get_files_for_upload,
|
|
9
|
+
)
|
|
10
|
+
from aristotlelib.api_request import AristotleRequestClient, AristotleAPIError
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import httpx
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
API_VERSION = "1"
|
|
7
|
+
BASE_URL = f"https://aristotle.harmonic.fun/api/v{API_VERSION}"
|
|
8
|
+
DEFAULT_TIMEOUT_SECONDS = 30
|
|
9
|
+
|
|
10
|
+
API_KEY: str | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def get_api_key() -> str:
|
|
14
|
+
global API_KEY
|
|
15
|
+
api_key = API_KEY or os.environ.get("ARISTOTLE_API_KEY")
|
|
16
|
+
if api_key is None:
|
|
17
|
+
raise ValueError(
|
|
18
|
+
"API key has not been set. Call aristotle.set_api_key() or set the ARISTOTLE_API_KEY environment variable."
|
|
19
|
+
)
|
|
20
|
+
return api_key
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def set_api_key(api_key: str) -> str:
|
|
24
|
+
global API_KEY
|
|
25
|
+
API_KEY = api_key
|
|
26
|
+
return API_KEY
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AristotleRequestClient:
|
|
30
|
+
"""Async HTTP client for the Aristotle API."""
|
|
31
|
+
|
|
32
|
+
def __init__(self):
|
|
33
|
+
self._client: httpx.AsyncClient | None = None
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self):
|
|
36
|
+
"""Async context manager entry."""
|
|
37
|
+
self._client = httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS)
|
|
38
|
+
return self
|
|
39
|
+
|
|
40
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
41
|
+
"""Async context manager exit."""
|
|
42
|
+
if self._client:
|
|
43
|
+
await self._client.aclose()
|
|
44
|
+
|
|
45
|
+
async def get(
|
|
46
|
+
self, endpoint: str, params: dict[str, Any] | None = None
|
|
47
|
+
) -> httpx.Response:
|
|
48
|
+
"""Make a GET request."""
|
|
49
|
+
return await self._make_request("GET", endpoint, params=params)
|
|
50
|
+
|
|
51
|
+
async def post(
|
|
52
|
+
self,
|
|
53
|
+
endpoint: str,
|
|
54
|
+
data: dict[str, Any] | None = None,
|
|
55
|
+
files: list[tuple[str, tuple[str, bytes, str]]] | None = None,
|
|
56
|
+
) -> httpx.Response:
|
|
57
|
+
"""Make a POST request."""
|
|
58
|
+
return await self._make_request("POST", endpoint, data=data, files=files)
|
|
59
|
+
|
|
60
|
+
async def put(
|
|
61
|
+
self, endpoint: str, data: dict[str, Any] | None = None
|
|
62
|
+
) -> httpx.Response:
|
|
63
|
+
"""Make a PUT request."""
|
|
64
|
+
return await self._make_request("PUT", endpoint, data=data)
|
|
65
|
+
|
|
66
|
+
async def delete(self, endpoint: str) -> httpx.Response:
|
|
67
|
+
"""Make a DELETE request."""
|
|
68
|
+
return await self._make_request("DELETE", endpoint)
|
|
69
|
+
|
|
70
|
+
async def _make_request(
|
|
71
|
+
self,
|
|
72
|
+
method: str,
|
|
73
|
+
endpoint: str,
|
|
74
|
+
data: dict[str, Any] | None = None,
|
|
75
|
+
params: dict[str, Any] | None = None,
|
|
76
|
+
files: list[tuple[str, tuple[str, bytes, str]]] | None = None,
|
|
77
|
+
) -> httpx.Response:
|
|
78
|
+
"""Make an HTTP request to the Aristotle API."""
|
|
79
|
+
url = f"{BASE_URL}/{endpoint.lstrip('/')}"
|
|
80
|
+
headers = {
|
|
81
|
+
"X-API-Key": get_api_key(),
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if not self._client:
|
|
85
|
+
self._client = httpx.AsyncClient(timeout=DEFAULT_TIMEOUT_SECONDS)
|
|
86
|
+
|
|
87
|
+
try:
|
|
88
|
+
if files:
|
|
89
|
+
# For file uploads, use multipart/form-data
|
|
90
|
+
files_data = []
|
|
91
|
+
for field_name, (file_path, file_content, content_type) in files:
|
|
92
|
+
files_data.append(
|
|
93
|
+
(field_name, (file_path, file_content, content_type))
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
response = await self._client.request(
|
|
97
|
+
method=method,
|
|
98
|
+
url=url,
|
|
99
|
+
data=data,
|
|
100
|
+
files=files_data,
|
|
101
|
+
params=params,
|
|
102
|
+
headers=headers,
|
|
103
|
+
)
|
|
104
|
+
else:
|
|
105
|
+
# For regular requests, use JSON
|
|
106
|
+
headers["Content-Type"] = "application/json"
|
|
107
|
+
response = await self._client.request(
|
|
108
|
+
method=method,
|
|
109
|
+
url=url,
|
|
110
|
+
json=data,
|
|
111
|
+
params=params,
|
|
112
|
+
headers=headers,
|
|
113
|
+
)
|
|
114
|
+
response.raise_for_status()
|
|
115
|
+
return response
|
|
116
|
+
except httpx.RequestError as e:
|
|
117
|
+
raise AristotleAPIError(f"Request failed: {str(e)}") from e
|
|
118
|
+
|
|
119
|
+
async def close(self):
|
|
120
|
+
"""Close the async client."""
|
|
121
|
+
if self._client:
|
|
122
|
+
await self._client.aclose()
|
|
123
|
+
self._client = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class AristotleAPIError(Exception):
|
|
127
|
+
"""Exception raised for API-related errors."""
|
|
128
|
+
|
|
129
|
+
pass
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
# Set up logger for this module
|
|
6
|
+
logger = logging.getLogger("aristotle")
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
MAX_FILE_SIZE_BYTES = 100 * 1024 * 1024
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class LeanProjectError(Exception):
|
|
13
|
+
"""Exception raised for Lean project-related errors."""
|
|
14
|
+
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def find_lean_project_root(start_path: Path) -> Path:
|
|
19
|
+
"""
|
|
20
|
+
Find the Lean project root by looking for project markers.
|
|
21
|
+
|
|
22
|
+
Finds the OUTERMOST project root to handle cases where we're starting
|
|
23
|
+
from within .lake/packages/ subdirectories.
|
|
24
|
+
|
|
25
|
+
Searches upward from start_path for:
|
|
26
|
+
- lakefile.lean (Lake build system)
|
|
27
|
+
- leanpkg.toml (legacy package manager)
|
|
28
|
+
- lean-toolchain file
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
start_path: Path to start searching from
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
Path: Outermost project root directory
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
ProjectAPIError: If no Lean project markers are found
|
|
38
|
+
"""
|
|
39
|
+
current = start_path.parent if start_path.is_file() else start_path
|
|
40
|
+
current = current.resolve()
|
|
41
|
+
project_markers = ["lakefile.lean", "leanpkg.toml", "lean-toolchain"]
|
|
42
|
+
found_root = None
|
|
43
|
+
|
|
44
|
+
# Search upward from the start path, keeping track of the outermost project root
|
|
45
|
+
while current != current.parent:
|
|
46
|
+
for marker in project_markers:
|
|
47
|
+
if (current / marker).exists():
|
|
48
|
+
logger.debug(f"Found project marker at {current} (marker: {marker})")
|
|
49
|
+
found_root = current
|
|
50
|
+
# Don't return immediately - keep searching for outer project roots
|
|
51
|
+
break
|
|
52
|
+
current = current.parent
|
|
53
|
+
|
|
54
|
+
if found_root:
|
|
55
|
+
logger.info(f"Found outermost project root at {found_root}")
|
|
56
|
+
return found_root
|
|
57
|
+
|
|
58
|
+
# No markers found - raise error
|
|
59
|
+
raise LeanProjectError(
|
|
60
|
+
f"No Lean project found. Could not find any of {project_markers} "
|
|
61
|
+
f"in the directory tree starting from {start_path}. "
|
|
62
|
+
"Please ensure you're running this from within a Lean project."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def validate_local_file_path(file_path: Path, project_root: Path | None = None) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Validate that a file path is safe to use.
|
|
69
|
+
|
|
70
|
+
Validates that this is a real file, and if provided, within the project root.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
file_path: Path to validate
|
|
74
|
+
project_root: Project root directory to ensure file is within (optional)
|
|
75
|
+
|
|
76
|
+
Raises:
|
|
77
|
+
ValueError: If path is invalid or outside project root
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
resolved_path = file_path.resolve(strict=True)
|
|
81
|
+
except (OSError, RuntimeError) as e:
|
|
82
|
+
raise ValueError(f"Invalid file path {file_path}: {e}")
|
|
83
|
+
|
|
84
|
+
if project_root is not None:
|
|
85
|
+
project_root_resolved = project_root.resolve()
|
|
86
|
+
try:
|
|
87
|
+
resolved_path.relative_to(project_root_resolved)
|
|
88
|
+
except ValueError:
|
|
89
|
+
raise ValueError(
|
|
90
|
+
f"File {resolved_path} is outside project root {project_root_resolved}"
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
if not resolved_path.is_file():
|
|
94
|
+
raise ValueError(f"Path {resolved_path} is not a file")
|
|
95
|
+
|
|
96
|
+
if resolved_path.suffix != ".lean":
|
|
97
|
+
raise ValueError(f"File {resolved_path} is not a Lean file")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_local_file_paths(
|
|
101
|
+
file_paths: list[Path], project_root: Path | None = None
|
|
102
|
+
) -> None:
|
|
103
|
+
"""
|
|
104
|
+
Validate that all paths in a list of local paths are safe to use.
|
|
105
|
+
"""
|
|
106
|
+
for file_path in file_paths:
|
|
107
|
+
validate_local_file_path(file_path, project_root)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def normalize_and_dedupe_paths(file_paths: list[Path] | list[str]) -> list[Path]:
|
|
111
|
+
"""
|
|
112
|
+
Normalize and remove duplicate file paths based on their resolved absolute paths.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
file_paths: List of file paths (can be strings or Path bojects) to normalize and deduplicate
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
List of unique Path objects (with duplicates removed)
|
|
119
|
+
"""
|
|
120
|
+
# Normalize to Path objects
|
|
121
|
+
normalized_paths = [Path(p) for p in file_paths]
|
|
122
|
+
|
|
123
|
+
seen: set[Path] = set()
|
|
124
|
+
unique_paths: list[Path] = []
|
|
125
|
+
|
|
126
|
+
for file_path in normalized_paths:
|
|
127
|
+
resolved = file_path.resolve()
|
|
128
|
+
if resolved not in seen:
|
|
129
|
+
seen.add(resolved)
|
|
130
|
+
unique_paths.append(file_path)
|
|
131
|
+
|
|
132
|
+
return unique_paths
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def read_file_safely(file_path: Path, max_size: int = MAX_FILE_SIZE_BYTES) -> bytes:
|
|
136
|
+
"""
|
|
137
|
+
Read a file safely, checking size before loading into memory.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
file_path: Path to file to read
|
|
141
|
+
max_size: Maximum allowed file size in bytes
|
|
142
|
+
|
|
143
|
+
Returns:
|
|
144
|
+
File contents as bytes
|
|
145
|
+
|
|
146
|
+
Raises:
|
|
147
|
+
FileSizeError: If file is too large
|
|
148
|
+
OSError: If file cannot be read
|
|
149
|
+
"""
|
|
150
|
+
file_size = file_path.stat().st_size
|
|
151
|
+
if file_size > max_size:
|
|
152
|
+
raise LeanProjectError(
|
|
153
|
+
f"File {file_path} is too large ({file_size} bytes). Maximum allowed size is {max_size} bytes."
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
with open(file_path, "rb") as f:
|
|
157
|
+
return f.read()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _clean_dependency_path(relative_path: Path) -> Path:
|
|
161
|
+
"""
|
|
162
|
+
Clean dependency paths by removing .lake/packages/PACKAGE_NAME/ prefix.
|
|
163
|
+
|
|
164
|
+
This strips build infrastructure artifacts from paths to ensure dependencies
|
|
165
|
+
are uploaded with the correct naming scheme.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
relative_path: The relative path to clean
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
Path: Cleaned path with package prefix removed if applicable
|
|
172
|
+
"""
|
|
173
|
+
path_str = str(relative_path)
|
|
174
|
+
|
|
175
|
+
if ".lake/packages/" not in path_str:
|
|
176
|
+
return relative_path
|
|
177
|
+
|
|
178
|
+
# handle nested packages
|
|
179
|
+
package = path_str.split(".lake/packages/")[-1]
|
|
180
|
+
package_and_path = package.split("/", 1)
|
|
181
|
+
if len(package_and_path) != 2:
|
|
182
|
+
raise ValueError(f"Invalid dependency path structure: {relative_path}")
|
|
183
|
+
_package_name, path = package_and_path
|
|
184
|
+
return Path(path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def get_files_for_upload(
|
|
188
|
+
file_paths: list[Path], project_root: Path | None = None
|
|
189
|
+
) -> list[tuple[str, bytes, str]]:
|
|
190
|
+
"""
|
|
191
|
+
Get files for upload, reading them safely and returning a list of tuples with the file path, contents, and file type.
|
|
192
|
+
"""
|
|
193
|
+
files: tuple[str, bytes, str] = []
|
|
194
|
+
for file_path in file_paths:
|
|
195
|
+
file_content = read_file_safely(file_path)
|
|
196
|
+
# Use relative path from project root
|
|
197
|
+
if project_root is not None:
|
|
198
|
+
file_path = file_path.resolve().relative_to(project_root)
|
|
199
|
+
files.append(
|
|
200
|
+
(str(_clean_dependency_path(file_path)), file_content, "text/plain")
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return files
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _extract_imports(lean_file_path: Path) -> list[str]:
|
|
207
|
+
"""
|
|
208
|
+
Extract local project import statements from a Lean file.
|
|
209
|
+
Filters out Mathlib imports.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
lean_file_path: Path to the Lean file
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
list[str]: List of local project import paths
|
|
216
|
+
"""
|
|
217
|
+
imports: list[str] = []
|
|
218
|
+
try:
|
|
219
|
+
with lean_file_path.open("r", encoding="utf-8") as f:
|
|
220
|
+
for line in f:
|
|
221
|
+
stripped_line = line.strip()
|
|
222
|
+
import_split = stripped_line.split("import ")
|
|
223
|
+
if len(import_split) != 2:
|
|
224
|
+
continue
|
|
225
|
+
|
|
226
|
+
import_path = import_split[1].strip()
|
|
227
|
+
|
|
228
|
+
# Skip Mathlib library imports - these are handled automatically
|
|
229
|
+
if import_path.startswith("Mathlib"):
|
|
230
|
+
logger.debug(f"Skipping standard library import: {import_path}")
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Only include local project imports
|
|
234
|
+
imports.append(import_path)
|
|
235
|
+
|
|
236
|
+
except Exception as e:
|
|
237
|
+
logger.error(f"Error reading file {lean_file_path}: {e}")
|
|
238
|
+
|
|
239
|
+
return imports
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _is_within_project(file_path: Path, project_root: Path) -> bool:
|
|
243
|
+
"""
|
|
244
|
+
Check if a file path is within the project directory.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
file_path: Path to check
|
|
248
|
+
project_root: Project root directory
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
bool: True if file is within project
|
|
252
|
+
"""
|
|
253
|
+
try:
|
|
254
|
+
file_path.resolve().relative_to(project_root.resolve())
|
|
255
|
+
return True
|
|
256
|
+
except ValueError:
|
|
257
|
+
return False
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def _resolve_import_to_file_path(
|
|
261
|
+
import_path: str, source_file_path: Path, project_root: Path
|
|
262
|
+
) -> Path | None:
|
|
263
|
+
"""
|
|
264
|
+
Resolve an import path to a local project file path.
|
|
265
|
+
Only resolves imports to files within the same project.
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
import_path: The import path (e.g., "MyProject.Utils", "./LocalFile", or "../Other")
|
|
269
|
+
source_file_path: Path to the file containing the import
|
|
270
|
+
project_root: Project root directory
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Path | None: Resolved file path or None if not a local project file
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
ImportResolutionError: If import resolution fails with detailed context
|
|
277
|
+
"""
|
|
278
|
+
source_file_path = Path(source_file_path).resolve()
|
|
279
|
+
source_dir = source_file_path.parent
|
|
280
|
+
|
|
281
|
+
attempted_paths: list[Path] = []
|
|
282
|
+
|
|
283
|
+
# Prevent self-imports
|
|
284
|
+
if (
|
|
285
|
+
source_file_path.stem == import_path
|
|
286
|
+
or source_file_path.stem == import_path.replace(".", "/").split("/")[-1]
|
|
287
|
+
):
|
|
288
|
+
logger.debug(f"Skipping self-import: {import_path}")
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
module_path = import_path.replace(".", "/") + ".lean"
|
|
292
|
+
|
|
293
|
+
# 1. Try relative to project root
|
|
294
|
+
candidate_path = (project_root / module_path).resolve()
|
|
295
|
+
attempted_paths.append(candidate_path)
|
|
296
|
+
if candidate_path.exists() and _is_within_project(candidate_path, project_root):
|
|
297
|
+
return candidate_path
|
|
298
|
+
|
|
299
|
+
# 2. Try in .lake/packages/ for third-party dependencies
|
|
300
|
+
lake_packages_base = project_root / ".lake" / "packages"
|
|
301
|
+
if lake_packages_base.exists():
|
|
302
|
+
for package_dir in lake_packages_base.iterdir():
|
|
303
|
+
if package_dir.is_dir():
|
|
304
|
+
candidate_path = (package_dir / module_path).resolve()
|
|
305
|
+
if candidate_path.exists():
|
|
306
|
+
return candidate_path
|
|
307
|
+
|
|
308
|
+
# 3. Try relative to source file directory
|
|
309
|
+
candidate_path = (source_dir / module_path).resolve()
|
|
310
|
+
attempted_paths.append(candidate_path)
|
|
311
|
+
if candidate_path.exists() and _is_within_project(candidate_path, project_root):
|
|
312
|
+
return candidate_path
|
|
313
|
+
|
|
314
|
+
logger.debug(
|
|
315
|
+
f"Import resolution failed for '{import_path}' from {source_file_path}"
|
|
316
|
+
)
|
|
317
|
+
return None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def _gather_all_lean_files_in_lean_package(package_dir: Path) -> set[Path]:
|
|
321
|
+
"""
|
|
322
|
+
Gather all .lean files in a Lean package directory, excluding subpackages.
|
|
323
|
+
|
|
324
|
+
A subpackage is identified by having its own lakefile.lean or being in .lake/packages/.
|
|
325
|
+
|
|
326
|
+
Args:
|
|
327
|
+
package_dir: Root directory of the Lean package
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
set[Path]: Set of all .lean files in the package (excluding subpackages)
|
|
331
|
+
"""
|
|
332
|
+
lean_files: set[Path] = set()
|
|
333
|
+
if not package_dir.exists() or not package_dir.is_dir():
|
|
334
|
+
return lean_files
|
|
335
|
+
|
|
336
|
+
for root, dirs, files in os.walk(package_dir):
|
|
337
|
+
root_path = Path(root)
|
|
338
|
+
|
|
339
|
+
if ".lake" in dirs:
|
|
340
|
+
dirs.remove(".lake")
|
|
341
|
+
|
|
342
|
+
dirs_to_remove: list[str] = []
|
|
343
|
+
for dir_name in dirs:
|
|
344
|
+
dir_path = root_path / dir_name
|
|
345
|
+
if (dir_path / "lakefile.lean").exists():
|
|
346
|
+
dirs_to_remove.append(dir_name)
|
|
347
|
+
|
|
348
|
+
for dir_name in dirs_to_remove:
|
|
349
|
+
dirs.remove(dir_name)
|
|
350
|
+
|
|
351
|
+
# Add all .lean files in the current directory
|
|
352
|
+
for file_name in files:
|
|
353
|
+
if file_name.endswith(".lean"):
|
|
354
|
+
file_path = root_path / file_name
|
|
355
|
+
lean_files.add(file_path.resolve())
|
|
356
|
+
|
|
357
|
+
return lean_files
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def gather_file_imports(
|
|
361
|
+
input_file_path: Path, project_root: Path | None = None
|
|
362
|
+
) -> set[Path]:
|
|
363
|
+
"""
|
|
364
|
+
Gather all files in the import tree starting from the given Lean file.
|
|
365
|
+
|
|
366
|
+
Args:
|
|
367
|
+
input_file_path: Path to the starting Lean file
|
|
368
|
+
project_root: Project root directory
|
|
369
|
+
|
|
370
|
+
Returns:
|
|
371
|
+
set[Path]: Set of all files in the import tree
|
|
372
|
+
"""
|
|
373
|
+
if project_root is None:
|
|
374
|
+
project_root = find_lean_project_root(input_file_path)
|
|
375
|
+
|
|
376
|
+
visited: set[Path] = set()
|
|
377
|
+
original_path = Path(input_file_path).resolve()
|
|
378
|
+
files_to_process: list[Path] = [original_path]
|
|
379
|
+
|
|
380
|
+
while files_to_process:
|
|
381
|
+
current_file = files_to_process.pop(0)
|
|
382
|
+
|
|
383
|
+
if current_file in visited:
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
visited.add(current_file)
|
|
387
|
+
imports = _extract_imports(current_file)
|
|
388
|
+
logger.debug(f"Found {len(imports)} imports in {current_file}")
|
|
389
|
+
|
|
390
|
+
for import_path in imports:
|
|
391
|
+
# Package imports
|
|
392
|
+
if "." not in import_path:
|
|
393
|
+
lake_packages_dir = project_root / ".lake" / "packages" / import_path
|
|
394
|
+
if lake_packages_dir.exists() and lake_packages_dir.is_dir():
|
|
395
|
+
logger.info(
|
|
396
|
+
f"Found package import '{import_path}', adding all files from {lake_packages_dir}"
|
|
397
|
+
)
|
|
398
|
+
package_files = _gather_all_lean_files_in_lean_package(
|
|
399
|
+
lake_packages_dir
|
|
400
|
+
)
|
|
401
|
+
for pkg_file in package_files:
|
|
402
|
+
if pkg_file not in visited:
|
|
403
|
+
visited.add(pkg_file)
|
|
404
|
+
continue
|
|
405
|
+
|
|
406
|
+
# Regular import resolution
|
|
407
|
+
resolved_file_path = _resolve_import_to_file_path(
|
|
408
|
+
import_path, current_file, project_root
|
|
409
|
+
)
|
|
410
|
+
if resolved_file_path and resolved_file_path.exists():
|
|
411
|
+
if resolved_file_path not in visited:
|
|
412
|
+
files_to_process.append(resolved_file_path)
|
|
413
|
+
else:
|
|
414
|
+
logger.error(
|
|
415
|
+
f"Could not resolve import '{import_path}' from {current_file}"
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if original_path in visited:
|
|
419
|
+
# don't add the original path as context
|
|
420
|
+
visited.remove(original_path)
|
|
421
|
+
return visited
|
aristotlelib/project.py
ADDED
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import pydantic # type: ignore
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import cast, overload, Literal
|
|
8
|
+
|
|
9
|
+
from aristotlelib import api_request, local_file_utils
|
|
10
|
+
|
|
11
|
+
# Set up logger for this module
|
|
12
|
+
logger = logging.getLogger("aristotle")
|
|
13
|
+
|
|
14
|
+
MAX_FILES_PER_REQUEST = 10
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class ProjectStatus(Enum):
|
|
18
|
+
NOT_STARTED = "NOT_STARTED"
|
|
19
|
+
QUEUED = "QUEUED"
|
|
20
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
21
|
+
COMPLETE = "COMPLETE"
|
|
22
|
+
FAILED = "FAILED"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class Project(pydantic.BaseModel):
|
|
26
|
+
project_id: str
|
|
27
|
+
status: ProjectStatus
|
|
28
|
+
created_at: datetime
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
async def from_id(cls, project_id: str) -> "Project":
|
|
32
|
+
project = Project(
|
|
33
|
+
project_id=project_id,
|
|
34
|
+
status=ProjectStatus.NOT_STARTED,
|
|
35
|
+
created_at=datetime.now(),
|
|
36
|
+
)
|
|
37
|
+
await project.refresh()
|
|
38
|
+
return project
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
async def create(
|
|
42
|
+
cls,
|
|
43
|
+
context_file_paths: list[Path] | list[str] | None = None,
|
|
44
|
+
validate_lean_project_root: bool = True,
|
|
45
|
+
) -> "Project":
|
|
46
|
+
"""Create a new project.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
context_file_paths: List of file paths to include in the project as context.
|
|
50
|
+
validate_lean_project_root: Whether to validate that these files are part of a valid Lean project.
|
|
51
|
+
Strongly recommended to set to True, but not required if you just want to reference a small number of
|
|
52
|
+
other imported files but don't have a working Lean project.
|
|
53
|
+
"""
|
|
54
|
+
context_file_paths = context_file_paths or []
|
|
55
|
+
if len(context_file_paths) > MAX_FILES_PER_REQUEST:
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"Maximum number of files to upload per request is {MAX_FILES_PER_REQUEST}"
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
file_paths = local_file_utils.normalize_and_dedupe_paths(context_file_paths)
|
|
61
|
+
project_root = None
|
|
62
|
+
if validate_lean_project_root:
|
|
63
|
+
example_file = file_paths[0] if file_paths else Path.cwd()
|
|
64
|
+
project_root = local_file_utils.find_lean_project_root(example_file)
|
|
65
|
+
|
|
66
|
+
local_file_utils.validate_local_file_paths(
|
|
67
|
+
file_paths, project_root=project_root
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
files_for_upload = local_file_utils.get_files_for_upload(
|
|
71
|
+
file_paths, project_root=project_root
|
|
72
|
+
)
|
|
73
|
+
async with api_request.AristotleRequestClient() as client:
|
|
74
|
+
response = await client.post(
|
|
75
|
+
"/project",
|
|
76
|
+
files=[("context", file) for file in files_for_upload],
|
|
77
|
+
)
|
|
78
|
+
return cls.model_validate(response.json())
|
|
79
|
+
|
|
80
|
+
async def add_context(
|
|
81
|
+
self,
|
|
82
|
+
context_file_paths: list[Path] | list[str],
|
|
83
|
+
batch_size: int = MAX_FILES_PER_REQUEST,
|
|
84
|
+
validate_lean_project_root: bool = True,
|
|
85
|
+
) -> None:
|
|
86
|
+
file_paths = local_file_utils.normalize_and_dedupe_paths(context_file_paths)
|
|
87
|
+
|
|
88
|
+
project_root = None
|
|
89
|
+
if validate_lean_project_root:
|
|
90
|
+
example_file = file_paths[0] if file_paths else Path.cwd()
|
|
91
|
+
project_root = local_file_utils.find_lean_project_root(example_file)
|
|
92
|
+
|
|
93
|
+
local_file_utils.validate_local_file_paths(
|
|
94
|
+
file_paths, project_root=project_root
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
async with api_request.AristotleRequestClient() as client:
|
|
98
|
+
for i in range(0, len(file_paths), batch_size):
|
|
99
|
+
batch = file_paths[i : i + batch_size]
|
|
100
|
+
await self._add_context(client, batch, project_root=project_root)
|
|
101
|
+
num_complete = min(i + batch_size, len(file_paths))
|
|
102
|
+
logger.info(
|
|
103
|
+
f"{num_complete} of {len(file_paths)} context files uploaded"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
async def _add_context(
|
|
107
|
+
self,
|
|
108
|
+
client: api_request.AristotleRequestClient,
|
|
109
|
+
context_file_paths: list[Path],
|
|
110
|
+
project_root: Path | None = None,
|
|
111
|
+
) -> None:
|
|
112
|
+
if len(context_file_paths) > MAX_FILES_PER_REQUEST:
|
|
113
|
+
raise ValueError(
|
|
114
|
+
f"Cannot upload more than {MAX_FILES_PER_REQUEST} files at once. Got {len(context_file_paths)}"
|
|
115
|
+
)
|
|
116
|
+
if len(context_file_paths) == 0:
|
|
117
|
+
logger.warning(f"No context files provided for project {self.project_id}")
|
|
118
|
+
return
|
|
119
|
+
|
|
120
|
+
local_file_utils.validate_local_file_paths(
|
|
121
|
+
context_file_paths, project_root=project_root
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
files_for_upload = local_file_utils.get_files_for_upload(
|
|
125
|
+
context_file_paths, project_root=project_root
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
response = await client.post(
|
|
129
|
+
f"/project/{self.project_id}/context",
|
|
130
|
+
files=[("context", file) for file in files_for_upload],
|
|
131
|
+
)
|
|
132
|
+
self._update_from_response(response.json())
|
|
133
|
+
|
|
134
|
+
@overload
|
|
135
|
+
async def solve(self, *, input_file_path: Path | str) -> None: ...
|
|
136
|
+
|
|
137
|
+
@overload
|
|
138
|
+
async def solve(self, *, input_content: str) -> None: ...
|
|
139
|
+
|
|
140
|
+
async def solve(
|
|
141
|
+
self,
|
|
142
|
+
input_file_path: Path | str | None = None,
|
|
143
|
+
input_content: str | None = None,
|
|
144
|
+
) -> None:
|
|
145
|
+
"""Solve the project with either an input file or input text.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
input_file_path: Path to a file to upload as input
|
|
149
|
+
input_content: Text content to send as input
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
assert self.status == ProjectStatus.NOT_STARTED, (
|
|
153
|
+
"This project has already been attempted; create a new project instead."
|
|
154
|
+
)
|
|
155
|
+
assert input_file_path is not None or input_content is not None, (
|
|
156
|
+
"Either input_file_path or input_content must be provided."
|
|
157
|
+
)
|
|
158
|
+
assert input_file_path is None or input_content is None, (
|
|
159
|
+
"Only one of input_file_path or input_content must be provided."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
async with api_request.AristotleRequestClient() as client:
|
|
163
|
+
if input_file_path is not None:
|
|
164
|
+
# Handle file upload case
|
|
165
|
+
file_path = Path(input_file_path)
|
|
166
|
+
file_content = local_file_utils.read_file_safely(file_path)
|
|
167
|
+
files = [("input_file", (str(file_path), file_content, "text/plain"))]
|
|
168
|
+
data = None
|
|
169
|
+
else:
|
|
170
|
+
# Handle text input case
|
|
171
|
+
data = {"input_text": input_content}
|
|
172
|
+
files = None
|
|
173
|
+
|
|
174
|
+
response = await client.post(
|
|
175
|
+
f"/project/{self.project_id}/solve",
|
|
176
|
+
data=data,
|
|
177
|
+
files=files,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
response_data = response.json()
|
|
181
|
+
self._update_from_response(response_data)
|
|
182
|
+
|
|
183
|
+
async def get_solution(self, output_path: Path | str | None = None) -> Path:
|
|
184
|
+
"""Download the solution file from the project result endpoint.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
output_path: Path where to save the downloaded file. If None, uses filename from response headers.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
Path to the downloaded file
|
|
191
|
+
"""
|
|
192
|
+
async with api_request.AristotleRequestClient() as client:
|
|
193
|
+
response = await client.get(f"/project/{self.project_id}/result")
|
|
194
|
+
|
|
195
|
+
if output_path is None:
|
|
196
|
+
# Try to get filename from Content-Disposition header
|
|
197
|
+
content_disposition = response.headers.get("content-disposition", "")
|
|
198
|
+
if "filename=" in content_disposition:
|
|
199
|
+
filename = content_disposition.split("filename=")[1].strip('"')
|
|
200
|
+
output_path = Path(filename)
|
|
201
|
+
else:
|
|
202
|
+
output_path = Path(f"{self.project_id}_solution.lean")
|
|
203
|
+
else:
|
|
204
|
+
output_path = Path(output_path)
|
|
205
|
+
|
|
206
|
+
output_path.write_bytes(response.content)
|
|
207
|
+
return output_path
|
|
208
|
+
|
|
209
|
+
async def refresh(self) -> None:
|
|
210
|
+
async with api_request.AristotleRequestClient() as client:
|
|
211
|
+
response = await client.get(f"/project/{self.project_id}")
|
|
212
|
+
response_data = response.json()
|
|
213
|
+
self._update_from_response(response_data)
|
|
214
|
+
|
|
215
|
+
def _update_from_response(self, response_data: dict) -> None:
|
|
216
|
+
updated_project = cast(Project, self.model_validate(response_data))
|
|
217
|
+
for field_name, field_value in updated_project.model_dump().items():
|
|
218
|
+
setattr(self, field_name, field_value)
|
|
219
|
+
|
|
220
|
+
@classmethod
|
|
221
|
+
async def list_projects(
|
|
222
|
+
cls, pagination_key: str | None = None, limit: int = 30
|
|
223
|
+
) -> tuple[list["Project"], str | None]:
|
|
224
|
+
"""List projects, ordered by creation date (most recent first).
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
pagination_key: Key to start from when paginating through projects.
|
|
228
|
+
limit: Maximum number of projects to return. Must be between 1 and 100.
|
|
229
|
+
|
|
230
|
+
Returns:
|
|
231
|
+
Tuple of list of projects and the new pagination key.
|
|
232
|
+
"""
|
|
233
|
+
assert 1 <= limit <= 100, "Limit must be between 1 and 100"
|
|
234
|
+
|
|
235
|
+
async with api_request.AristotleRequestClient() as client:
|
|
236
|
+
response = await client.get(
|
|
237
|
+
"/project", params={"pagination_key": pagination_key, "limit": limit}
|
|
238
|
+
)
|
|
239
|
+
response_data = response.json()
|
|
240
|
+
projects: list["Project"] = [
|
|
241
|
+
cast("Project", cls.model_validate(project))
|
|
242
|
+
for project in response_data["projects"]
|
|
243
|
+
]
|
|
244
|
+
pagination_key = response_data.get("pagination_key")
|
|
245
|
+
assert pagination_key is None or isinstance(pagination_key, str)
|
|
246
|
+
return projects, pagination_key
|
|
247
|
+
|
|
248
|
+
@overload
|
|
249
|
+
@classmethod
|
|
250
|
+
async def prove_from_file(
|
|
251
|
+
cls,
|
|
252
|
+
input_file_path: Path | str,
|
|
253
|
+
*,
|
|
254
|
+
auto_add_imports: Literal[True],
|
|
255
|
+
validate_lean_project: Literal[True] = True,
|
|
256
|
+
polling_interval_seconds: int = 30,
|
|
257
|
+
max_polling_failures: int = 3,
|
|
258
|
+
) -> Path: ...
|
|
259
|
+
|
|
260
|
+
@overload
|
|
261
|
+
@classmethod
|
|
262
|
+
async def prove_from_file(
|
|
263
|
+
cls,
|
|
264
|
+
input_file_path: Path | str,
|
|
265
|
+
*,
|
|
266
|
+
auto_add_imports: Literal[False] = False,
|
|
267
|
+
context_file_paths: list[Path] | list[str] | None = None,
|
|
268
|
+
validate_lean_project: bool = True,
|
|
269
|
+
polling_interval_seconds: int = 30,
|
|
270
|
+
max_polling_failures: int = 3,
|
|
271
|
+
) -> Path: ...
|
|
272
|
+
|
|
273
|
+
@classmethod
|
|
274
|
+
async def prove_from_file(
|
|
275
|
+
cls,
|
|
276
|
+
input_file_path: Path | str,
|
|
277
|
+
auto_add_imports: bool = True,
|
|
278
|
+
context_file_paths: list[Path] | list[str] | None = None,
|
|
279
|
+
validate_lean_project: bool = True,
|
|
280
|
+
polling_interval_seconds: int = 30,
|
|
281
|
+
max_polling_failures: int = 3,
|
|
282
|
+
) -> Path:
|
|
283
|
+
"""Proves the input content.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
input_file_path: Path to the input file
|
|
287
|
+
auto_add_imports: Whether to automatically add imports from the input file as context to the project.
|
|
288
|
+
Requires that the input file is part of a valid Lean project.
|
|
289
|
+
context_file_paths: List of file paths to add as context to the project, manually.
|
|
290
|
+
validate_lean_project: Whether to validate that the input file is part of a valid Lean project.
|
|
291
|
+
polling_interval_seconds: Interval in seconds to poll for the project status.
|
|
292
|
+
max_polling_failures: Maximum number of polling failures before raising an error.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
The file path to the solution
|
|
296
|
+
"""
|
|
297
|
+
logger.info("Validating input...")
|
|
298
|
+
input_file_path = Path(input_file_path)
|
|
299
|
+
if validate_lean_project:
|
|
300
|
+
project_root = local_file_utils.find_lean_project_root(input_file_path)
|
|
301
|
+
else:
|
|
302
|
+
project_root = None
|
|
303
|
+
local_file_utils.validate_local_file_path(
|
|
304
|
+
input_file_path, project_root=project_root
|
|
305
|
+
)
|
|
306
|
+
logger.info("Input Validated.")
|
|
307
|
+
|
|
308
|
+
logger.info("Creating project...")
|
|
309
|
+
project = await cls.create(validate_lean_project_root=validate_lean_project)
|
|
310
|
+
logger.info(f"Created project {project.project_id}")
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
if auto_add_imports:
|
|
314
|
+
logger.info("Adding imports to project...")
|
|
315
|
+
assert context_file_paths is None, (
|
|
316
|
+
"context_file_paths cannot be provided when auto_add_imports is True"
|
|
317
|
+
)
|
|
318
|
+
assert validate_lean_project, (
|
|
319
|
+
"validate_lean_project must be True when auto_add_imports is True"
|
|
320
|
+
)
|
|
321
|
+
assert project_root is not None
|
|
322
|
+
context_file_paths = list(
|
|
323
|
+
local_file_utils.gather_file_imports(input_file_path, project_root)
|
|
324
|
+
)
|
|
325
|
+
await project.add_context(
|
|
326
|
+
context_file_paths, validate_lean_project_root=True
|
|
327
|
+
)
|
|
328
|
+
logger.info(f"Added {len(context_file_paths)} imports to project")
|
|
329
|
+
elif context_file_paths is not None:
|
|
330
|
+
logger.info("Adding context files to project...")
|
|
331
|
+
await project.add_context(
|
|
332
|
+
context_file_paths, validate_lean_project_root=validate_lean_project
|
|
333
|
+
)
|
|
334
|
+
logger.info(f"Added {len(context_file_paths)} context files to project")
|
|
335
|
+
|
|
336
|
+
await project.solve(input_file_path=input_file_path)
|
|
337
|
+
|
|
338
|
+
num_polling_failures = 0
|
|
339
|
+
while project.status not in (ProjectStatus.COMPLETE, ProjectStatus.FAILED):
|
|
340
|
+
try:
|
|
341
|
+
logger.info(
|
|
342
|
+
f"Project status: {project.status} - sleeping for {polling_interval_seconds} seconds..."
|
|
343
|
+
)
|
|
344
|
+
await asyncio.sleep(polling_interval_seconds)
|
|
345
|
+
await project.refresh()
|
|
346
|
+
except api_request.AristotleAPIError:
|
|
347
|
+
num_polling_failures += 1
|
|
348
|
+
if num_polling_failures >= max_polling_failures:
|
|
349
|
+
logger.error(
|
|
350
|
+
"Too many errors polling for project status. Your project might still be running; try checking on it later with project id {project.project_id}."
|
|
351
|
+
)
|
|
352
|
+
raise
|
|
353
|
+
logger.warning(
|
|
354
|
+
f"Error polling for project status. {num_polling_failures} failures so far. Sleeping for {polling_interval_seconds * num_polling_failures} seconds."
|
|
355
|
+
)
|
|
356
|
+
await asyncio.sleep(polling_interval_seconds * num_polling_failures)
|
|
357
|
+
|
|
358
|
+
if project.status != ProjectStatus.COMPLETE:
|
|
359
|
+
raise api_request.AristotleAPIError(
|
|
360
|
+
"Project failed due to an internal error. The team at Harmonic has been notified; please try again."
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
logger.info("Solve complete! Getting solution...")
|
|
364
|
+
solution_file_path = await project.get_solution()
|
|
365
|
+
logger.info(f"Solution saved to {solution_file_path}")
|
|
366
|
+
return solution_file_path
|
|
367
|
+
finally:
|
|
368
|
+
if project.status in (ProjectStatus.QUEUED, ProjectStatus.IN_PROGRESS):
|
|
369
|
+
logger.info(
|
|
370
|
+
f"Project {project.project_id} is still running. You can manually check on it any time with Project.from_id('{project.project_id}')"
|
|
371
|
+
)
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: aristotlelib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Aristotle SDK - Python library for automated theorem proving with Lean
|
|
5
|
+
Author: Harmonic
|
|
6
|
+
Maintainer: Harmonic
|
|
7
|
+
Project-URL: Homepage, https://aristotle.harmonic.fun
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: pydantic>=2.0.0
|
|
12
|
+
Requires-Dist: httpx>=0.24.0
|
|
13
|
+
Dynamic: license-file
|
|
14
|
+
|
|
15
|
+
# Aristotle SDK
|
|
16
|
+
|
|
17
|
+
The Aristotle SDK is a Python library that provides tools and utilities for interacting with the Aristotle API, enabling automated theorem proving for Lean projects.
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install aristotlelib
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Quick Start
|
|
27
|
+
|
|
28
|
+
### 1. Set up your API key
|
|
29
|
+
|
|
30
|
+
```python
|
|
31
|
+
import aristotlelib
|
|
32
|
+
|
|
33
|
+
# Set your API key
|
|
34
|
+
aristotlelib.set_api_key("your-api-key-here")
|
|
35
|
+
|
|
36
|
+
# Or set it via environment variable
|
|
37
|
+
# export ARISTOTLE_API_KEY="your-api-key-here"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### 2. Prove a theorem from a file
|
|
41
|
+
|
|
42
|
+
The simplest way to use Aristotle is to prove a theorem from a Lean file:
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
import asyncio
|
|
46
|
+
|
|
47
|
+
async def main():
|
|
48
|
+
# Prove a theorem from a Lean file
|
|
49
|
+
solution_path = await aristotlelib.Project.prove_from_file("path/to/your/theorem.lean")
|
|
50
|
+
print(f"Solution saved to: {solution_path}")
|
|
51
|
+
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 3. Manual project management
|
|
56
|
+
|
|
57
|
+
For more control, you can manage projects manually:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
import asyncio
|
|
61
|
+
import aristotlelib
|
|
62
|
+
from pathlib import Path
|
|
63
|
+
|
|
64
|
+
async def main():
|
|
65
|
+
# Create a new project
|
|
66
|
+
project = await aristotlelib.Project.create()
|
|
67
|
+
print(f"Created project: {project.project_id}")
|
|
68
|
+
|
|
69
|
+
# Add context files
|
|
70
|
+
await project.add_context(["path/to/context1.lean", "path/to/context2.lean"])
|
|
71
|
+
|
|
72
|
+
# Solve with input content
|
|
73
|
+
await project.solve(input_content="theorem my_theorem : True := trivial")
|
|
74
|
+
|
|
75
|
+
# Wait for completion and get solution
|
|
76
|
+
while project.status not in [aristotlelib.ProjectStatus.COMPLETE, aristotlelib.ProjectStatus.FAILED]:
|
|
77
|
+
await asyncio.sleep(30) # Poll every 30 seconds
|
|
78
|
+
await project.refresh()
|
|
79
|
+
print(f"Status: {project.status}")
|
|
80
|
+
|
|
81
|
+
if project.status == aristotlelib.ProjectStatus.COMPLETE:
|
|
82
|
+
solution_path = await project.get_solution()
|
|
83
|
+
print(f"Solution saved to: {solution_path}")
|
|
84
|
+
|
|
85
|
+
asyncio.run(main())
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## API Reference
|
|
89
|
+
|
|
90
|
+
### Project Class
|
|
91
|
+
|
|
92
|
+
The main class for interacting with Aristotle projects.
|
|
93
|
+
|
|
94
|
+
#### `Project.create(context_file_paths=None, validate_lean_project_root=True)`
|
|
95
|
+
|
|
96
|
+
Create a new Aristotle project.
|
|
97
|
+
|
|
98
|
+
**Parameters:**
|
|
99
|
+
- `context_file_paths` (list[Path | str], optional): List of file paths to include as context
|
|
100
|
+
- `validate_lean_project_root` (bool): Whether to validate Lean project structure (recommended: True)
|
|
101
|
+
|
|
102
|
+
**Returns:** `Project` instance
|
|
103
|
+
|
|
104
|
+
#### `Project.prove_from_file(input_file_path, auto_add_imports=True, context_file_paths=None, validate_lean_project=True, polling_interval_seconds=30, max_polling_failures=3)`
|
|
105
|
+
|
|
106
|
+
Convenience method to prove a theorem from a file with automatic import resolution.
|
|
107
|
+
|
|
108
|
+
**Parameters:**
|
|
109
|
+
- `input_file_path` (Path | str): Path to the input Lean file
|
|
110
|
+
- `auto_add_imports` (bool): Automatically add imported files as context
|
|
111
|
+
- `context_file_paths` (list[Path | str], optional): Manual context files
|
|
112
|
+
- `validate_lean_project` (bool): Validate Lean project structure
|
|
113
|
+
- `polling_interval_seconds` (int): Seconds between status checks
|
|
114
|
+
- `max_polling_failures` (int): Max polling failures before giving up
|
|
115
|
+
|
|
116
|
+
**Returns:** `Path` to the solution file
|
|
117
|
+
|
|
118
|
+
#### `project.add_context(context_file_paths, batch_size=10, validate_lean_project_root=True)`
|
|
119
|
+
|
|
120
|
+
Add context files to an existing project.
|
|
121
|
+
|
|
122
|
+
**Parameters:**
|
|
123
|
+
- `context_file_paths` (list[Path | str]): Files to add as context
|
|
124
|
+
- `batch_size` (int): Files to upload per batch (max 10)
|
|
125
|
+
- `validate_lean_project_root` (bool): Validate project structure
|
|
126
|
+
|
|
127
|
+
#### `project.solve(input_file_path=None, input_content=None)`
|
|
128
|
+
|
|
129
|
+
Solve the project with either a file or text content.
|
|
130
|
+
|
|
131
|
+
**Parameters:**
|
|
132
|
+
- `input_file_path` (Path | str, optional): Path to input file
|
|
133
|
+
- `input_content` (str, optional): Text content to solve
|
|
134
|
+
|
|
135
|
+
**Note:** Exactly one of `input_file_path` or `input_content` must be provided.
|
|
136
|
+
|
|
137
|
+
#### `project.get_solution(output_path=None)`
|
|
138
|
+
|
|
139
|
+
Download the solution file.
|
|
140
|
+
|
|
141
|
+
**Parameters:**
|
|
142
|
+
- `output_path` (Path | str, optional): Where to save the solution
|
|
143
|
+
|
|
144
|
+
**Returns:** `Path` to the downloaded solution file
|
|
145
|
+
|
|
146
|
+
#### `project.refresh()`
|
|
147
|
+
|
|
148
|
+
Refresh the project status from the API.
|
|
149
|
+
|
|
150
|
+
### Project Status
|
|
151
|
+
|
|
152
|
+
```python
|
|
153
|
+
class ProjectStatus(Enum):
|
|
154
|
+
NOT_STARTED = "NOT_STARTED"
|
|
155
|
+
QUEUED = "QUEUED"
|
|
156
|
+
IN_PROGRESS = "IN_PROGRESS"
|
|
157
|
+
COMPLETE = "COMPLETE"
|
|
158
|
+
FAILED = "FAILED"
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
### Error Handling
|
|
162
|
+
|
|
163
|
+
The SDK provides several exception types:
|
|
164
|
+
|
|
165
|
+
- `AristotleAPIError`: API-related errors
|
|
166
|
+
- `LeanProjectError`: Lean project validation errors
|
|
167
|
+
|
|
168
|
+
## Lean Project Requirements
|
|
169
|
+
|
|
170
|
+
Aristotle works best with properly structured Lean projects. Your project should have:
|
|
171
|
+
|
|
172
|
+
- A `lakefile.lean` (Lake build system) or `leanpkg.toml` (legacy)
|
|
173
|
+
- A `lean-toolchain` file
|
|
174
|
+
- Proper import structure
|
|
175
|
+
|
|
176
|
+
The SDK will automatically:
|
|
177
|
+
- Detect your project root
|
|
178
|
+
- Validate file paths are within the project
|
|
179
|
+
- Resolve imports to include dependencies
|
|
180
|
+
- Handle file size limits (100MB max per file)
|
|
181
|
+
|
|
182
|
+
## Examples
|
|
183
|
+
|
|
184
|
+
### Basic theorem proving
|
|
185
|
+
|
|
186
|
+
```python
|
|
187
|
+
import asyncio
|
|
188
|
+
import aristotle
|
|
189
|
+
|
|
190
|
+
async def prove_simple_theorem():
|
|
191
|
+
# Set API key
|
|
192
|
+
aristotlelib.set_api_key("your-key")
|
|
193
|
+
|
|
194
|
+
# Prove a simple theorem
|
|
195
|
+
solution = await aristotlelib.Project.prove_from_file("examples/simple.lean")
|
|
196
|
+
print(f"Proof completed: {solution}")
|
|
197
|
+
|
|
198
|
+
asyncio.run(prove_simple_theorem())
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
### Working with existing projects
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
import asyncio
|
|
205
|
+
import aristotle
|
|
206
|
+
|
|
207
|
+
async def work_with_existing_project():
|
|
208
|
+
# Load an existing project
|
|
209
|
+
project = await aristotlelib.Project.from_id("existing-project-id")
|
|
210
|
+
|
|
211
|
+
# Check status
|
|
212
|
+
print(f"Project status: {project.status}")
|
|
213
|
+
|
|
214
|
+
if project.status == aristotlelib.ProjectStatus.COMPLETE:
|
|
215
|
+
solution = await project.get_solution()
|
|
216
|
+
print(f"Solution available at: {solution}")
|
|
217
|
+
|
|
218
|
+
asyncio.run(work_with_existing_project())
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
### Listing projects
|
|
222
|
+
|
|
223
|
+
```python
|
|
224
|
+
import asyncio
|
|
225
|
+
import aristotle
|
|
226
|
+
|
|
227
|
+
async def list_projects():
|
|
228
|
+
projects, pagination_key = await aristotlelib.Project.list_projects(limit=10)
|
|
229
|
+
|
|
230
|
+
for project in projects:
|
|
231
|
+
print(f"Project {project.project_id}: {project.status}")
|
|
232
|
+
|
|
233
|
+
# Get next page if available
|
|
234
|
+
if pagination_key:
|
|
235
|
+
more_projects, pagination_key = await aristotlelib.Project.list_projects(pagination_key=pagination_key)
|
|
236
|
+
print(f"Found {len(more_projects)} more projects")
|
|
237
|
+
|
|
238
|
+
asyncio.run(list_projects())
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
## Logging
|
|
242
|
+
|
|
243
|
+
The SDK uses Python's standard logging module. To see debug and info messages from the SDK, configure logging in your application:
|
|
244
|
+
|
|
245
|
+
```python
|
|
246
|
+
import logging
|
|
247
|
+
import aristotle
|
|
248
|
+
|
|
249
|
+
# Configure logging to see SDK messages
|
|
250
|
+
logging.basicConfig(
|
|
251
|
+
level=logging.INFO,
|
|
252
|
+
format="%(levelname)s - %(name)s - %(message)s"
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Or configure just the aristotle logger
|
|
256
|
+
logging.getLogger('aristotle').setLevel(logging.INFO)
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
This will show helpful messages to track the current status.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
aristotlelib/__init__.py,sha256=mp3p5DanjUA3LmL4JlyeCrsCksqh2WJ50lnjyPHXlFI,341
|
|
2
|
+
aristotlelib/api_request.py,sha256=duFqGIs_0kXwMSfs_cL8fhjlCfHmICSn50Jz0m7S4HE,3992
|
|
3
|
+
aristotlelib/local_file_utils.py,sha256=0DwrslgGJ9eTsztcAvfyfa_htEUFm0Tycb-4iILVFFU,13534
|
|
4
|
+
aristotlelib/project.py,sha256=mwuEdDT98BFSmZFvI6DS1HGy_VC8Zg-SKv7Nyi5O12Y,15014
|
|
5
|
+
aristotlelib-0.1.0.dist-info/licenses/LICENSE,sha256=ycH5WVQoUAehbfn8UML4uh5jwiEnfE1tcXPLWO9KQ9I,944
|
|
6
|
+
aristotlelib-0.1.0.dist-info/METADATA,sha256=FwtkCIUe0N00RHaA6Tvy0K2O9CM9oSDbr58owZ6qSE4,7053
|
|
7
|
+
aristotlelib-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
|
8
|
+
aristotlelib-0.1.0.dist-info/top_level.txt,sha256=Xwn3eTy1r_fXC5teogOo9Oekj7H5PM6CGFLjSd-3SK4,13
|
|
9
|
+
aristotlelib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Copyright (c) 2025 Concordance Inc. dba Harmonic. All rights reserved.
|
|
2
|
+
|
|
3
|
+
Concordance Inc. dba Harmonic grants you a limited, non-exclusive, non-transferable,
|
|
4
|
+
revocable license to install and use this software solely to access the Aristotle
|
|
5
|
+
API under a valid API key and in accordance with the Aristotle API Terms of Service
|
|
6
|
+
available at https://aristotle.harmonic.fun/api-terms-of-use
|
|
7
|
+
|
|
8
|
+
You may not modify, distribute, sublicense, or reverse-engineer this software,
|
|
9
|
+
except as expressly permitted by Concordance Inc. dba Harmonic in writing.
|
|
10
|
+
|
|
11
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
|
|
12
|
+
INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
|
13
|
+
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE COPYRIGHT HOLDERS
|
|
14
|
+
BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
|
|
15
|
+
CONTRACT, TORT OR OTHERWISE, ARISING FROM OR RELATING TO THE SOFTWARE OR ITS USE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
aristotlelib
|