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.
@@ -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
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -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