agis-spatial 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.
agis/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """agis: natural-language GIS tasks on your local machine."""
2
+
3
+ __version__ = "0.1.0"
agis/cli.py ADDED
@@ -0,0 +1,349 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shlex
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Annotated
10
+
11
+ import typer
12
+ from prompt_toolkit.completion import Completer, Completion
13
+ from rich.console import Console
14
+ from rich.markup import escape
15
+
16
+ from agis import __version__
17
+ from agis import config as config_mod
18
+ from agis.errors import AgisError, PlanError, StepError
19
+
20
+ # A file token, so "roads.gpkg" is one word and Tab can finish it.
21
+ _FILE_WORD = re.compile(r"[^\s\"']+")
22
+
23
+ console = Console()
24
+ err_console = Console(stderr=True)
25
+
26
+ app = typer.Typer(
27
+ add_completion=False,
28
+ no_args_is_help=True,
29
+ rich_markup_mode="rich",
30
+ help="Natural-language GIS on your own machine.\n\n"
31
+ "Run [bold]agis[/bold] for an interactive prompt, or pass one request:\n"
32
+ '[bold]agis "clip roads.gpkg to county.shp and reproject to 3857"[/bold]',
33
+ )
34
+
35
+ ModelOpt = Annotated[str | None, typer.Option("--model", "-m", help="Ollama model to use.")]
36
+ HostOpt = Annotated[str | None, typer.Option("--host", help="Ollama URL.", show_default=False)]
37
+ DirOpt = Annotated[Path, typer.Option("--dir", "-C", help="Working directory.", file_okay=False,
38
+ exists=True)]
39
+
40
+
41
+ def _version(value: bool):
42
+ if value:
43
+ console.print(f"agis {__version__}")
44
+ raise typer.Exit()
45
+
46
+
47
+ @app.callback()
48
+ def _root(
49
+ version: Annotated[bool, typer.Option("--version", callback=_version, is_eager=True,
50
+ help="Show the version and exit.")] = False,
51
+ ):
52
+ pass
53
+
54
+
55
+ def _session(model: str | None, host: str | None, directory: Path):
56
+ from agis.llm import make_backend
57
+ from agis.session import Session
58
+
59
+ cfg = config_mod.load()
60
+ backend = make_backend(model or cfg.model, host or cfg.host)
61
+ return Session(backend=backend, cwd=directory.resolve(), console=console)
62
+
63
+
64
+ @app.command("run")
65
+ def run_cmd(
66
+ request: Annotated[list[str], typer.Argument(help="What you want done, in plain English.")],
67
+ dry_run: Annotated[bool, typer.Option("--dry-run", "-n", help="Show the plan only.")] = False,
68
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before running.")] = False,
69
+ force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing outputs.")] = False,
70
+ verbose: Annotated[bool, typer.Option("--verbose", "-v", help="Show context and raw plans.")] = False,
71
+ model: ModelOpt = None,
72
+ host: HostOpt = None,
73
+ directory: DirOpt = Path("."),
74
+ ):
75
+ """Plan and run a request. This is the default command, so `agis run` can be omitted."""
76
+ from agis.session import Options
77
+
78
+ session = _session(model, host, directory)
79
+ with _errors():
80
+ session.handle(" ".join(request), Options(dry_run=dry_run, yes=yes, force=force,
81
+ verbose=verbose))
82
+
83
+
84
+ @app.command()
85
+ def repl(
86
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Don't ask before running.")] = False,
87
+ force: Annotated[bool, typer.Option("--force", "-f", help="Overwrite existing outputs.")] = False,
88
+ verbose: Annotated[bool, typer.Option("--verbose", "-v")] = False,
89
+ model: ModelOpt = None,
90
+ host: HostOpt = None,
91
+ directory: DirOpt = Path("."),
92
+ ):
93
+ """Interactive mode. Later requests can refer to files made earlier ("now buffer that")."""
94
+ from agis.session import Options
95
+
96
+ session = _session(model, host, directory)
97
+ options = Options(yes=yes, force=force, verbose=verbose)
98
+ completer = DatasetCompleter(session.cwd)
99
+ prompt = _prompt_session(completer)
100
+ console.print(f"agis {__version__} using [bold]{session.backend.model}[/bold] in "
101
+ f"{session.cwd}. Type a request, or 'exit' to quit. "
102
+ "ls, cd, and pwd run locally.")
103
+ while True:
104
+ try:
105
+ request = _read_request(prompt)
106
+ except EOFError:
107
+ console.print()
108
+ return
109
+ except KeyboardInterrupt:
110
+ # At the prompt, cancel this line and keep the session. A Ctrl-C during a
111
+ # run is handled inside the loop below and also returns here.
112
+ if prompt is None:
113
+ console.print()
114
+ return
115
+ console.print()
116
+ continue
117
+ if request.lower() in ("exit", "quit", ":q"):
118
+ return
119
+ if not request:
120
+ continue
121
+ if try_builtin(request, session, completer, console):
122
+ continue
123
+ with _errors(exit_on_error=False):
124
+ session.handle(request, options)
125
+
126
+
127
+ @app.command()
128
+ def setup(
129
+ model: ModelOpt = None,
130
+ host: HostOpt = None,
131
+ yes: Annotated[bool, typer.Option("--yes", "-y", help="Accept the recommended model.")] = False,
132
+ ):
133
+ """Check Ollama, download the recommended model, and save the choice."""
134
+ from agis.setup import run_setup
135
+
136
+ with _errors():
137
+ run_setup(console, model=model, host=host, assume_yes=yes)
138
+
139
+
140
+ @app.command()
141
+ def doctor(host: HostOpt = None):
142
+ """Print environment details that are useful for troubleshooting and bug reports."""
143
+ from agis.setup import run_doctor
144
+
145
+ with _errors():
146
+ if not run_doctor(console, host=host):
147
+ raise typer.Exit(1)
148
+
149
+
150
+ @app.command("eval")
151
+ def eval_cmd(
152
+ model: ModelOpt = None,
153
+ host: HostOpt = None,
154
+ cases: Annotated[Path, typer.Option("--cases", help="YAML file of eval cases.")] = Path(
155
+ "tests/evals/cases.yaml"),
156
+ only: Annotated[str | None, typer.Option("--only", help="Run cases whose id contains this.")] = None,
157
+ show: Annotated[bool, typer.Option("--show", help="Print every plan, not only failures.")] = False,
158
+ ):
159
+ """Score how well a model turns the eval prompts into the expected plans."""
160
+ from agis.evals import run_evals
161
+
162
+ cfg = config_mod.load()
163
+ with _errors():
164
+ score = run_evals(console, cases, model or cfg.model, host or cfg.host, only=only,
165
+ show=show)
166
+ raise typer.Exit(0 if score == 1.0 else 1)
167
+
168
+
169
+ class _errors:
170
+ """Print AgisErrors nicely instead of a traceback."""
171
+
172
+ def __init__(self, exit_on_error: bool = True):
173
+ self.exit_on_error = exit_on_error
174
+
175
+ def __enter__(self):
176
+ return self
177
+
178
+ def __exit__(self, exc_type, exc, tb):
179
+ if exc_type is None or not issubclass(exc_type, (AgisError, KeyboardInterrupt)):
180
+ return False
181
+ if exc_type is KeyboardInterrupt:
182
+ err_console.print("\nInterrupted.")
183
+ elif isinstance(exc, PlanError) and len(exc.problems) > 1:
184
+ err_console.print("[red]The plan can't run:[/red]")
185
+ for problem in exc.problems:
186
+ err_console.print(f" - {escape(problem)}")
187
+ elif isinstance(exc, PlanError):
188
+ err_console.print(f"[red]The plan can't run:[/red] {escape(exc.problems[0])}")
189
+ elif isinstance(exc, StepError):
190
+ err_console.print(f"[red]Step {exc.index} ({exc.op}) failed:[/red] "
191
+ f"{escape(str(exc.cause))}")
192
+ else:
193
+ err_console.print(f"[red]Error:[/red] {escape(str(exc))}")
194
+ if self.exit_on_error:
195
+ raise typer.Exit(130 if exc_type is KeyboardInterrupt else 1)
196
+ return True
197
+
198
+
199
+ SUBCOMMANDS = {"run", "repl", "setup", "doctor", "eval"}
200
+ ROOT_FLAGS = {"--help", "-h", "--version"}
201
+ # Flags accepted by both `run` and `repl`. True when the next argv token is the value,
202
+ # unless the flag is written as --name=value.
203
+ _SHARED_FLAGS = {
204
+ "-y": False, "--yes": False,
205
+ "-f": False, "--force": False,
206
+ "-v": False, "--verbose": False,
207
+ "-m": True, "--model": True,
208
+ "--host": True,
209
+ "-C": True, "--dir": True,
210
+ }
211
+
212
+
213
+ def argv_for(args: list[str]) -> list[str]:
214
+ """Turn a bare invocation into the repl, and a request into `run`.
215
+
216
+ Flags with no request (`agis -C ~/data`) also open the repl. A run-only flag such as
217
+ `--dry-run` still goes to `run`, so a missing request is reported there.
218
+ """
219
+ if not args:
220
+ return ["repl"]
221
+ if args[0] in SUBCOMMANDS or args[0] in ROOT_FLAGS:
222
+ return list(args)
223
+ if _only_shared_flags(args):
224
+ return ["repl", *args]
225
+ return ["run", *args]
226
+
227
+
228
+ def _only_shared_flags(args: list[str]) -> bool:
229
+ index = 0
230
+ while index < len(args):
231
+ name, separator, _value = args[index].partition("=")
232
+ if name not in _SHARED_FLAGS:
233
+ return False
234
+ takes_value = _SHARED_FLAGS[name] and not separator
235
+ index += 2 if takes_value else 1
236
+ if takes_value and index > len(args):
237
+ return False
238
+ return True
239
+
240
+
241
+ def _prompt_session(completer: DatasetCompleter):
242
+ """Line editor with history and file completion, or None when stdin is piped."""
243
+ if not sys.stdin.isatty():
244
+ return None
245
+ from prompt_toolkit import PromptSession
246
+ from prompt_toolkit.history import FileHistory
247
+
248
+ path = config_mod.config_path().parent / "history"
249
+ path.parent.mkdir(parents=True, exist_ok=True)
250
+ return PromptSession(history=FileHistory(str(path)), completer=completer,
251
+ complete_while_typing=False)
252
+
253
+
254
+ _BUILTINS = {"ls", "cd", "pwd"}
255
+
256
+
257
+ def try_builtin(line: str, session, completer: DatasetCompleter | None, console: Console) -> bool:
258
+ """Run ls, cd, or pwd locally. Anything else, including a pipe, returns False."""
259
+ if any(character in line for character in "|;&><"):
260
+ return False
261
+ try:
262
+ argv = shlex.split(line)
263
+ except ValueError:
264
+ return False
265
+ if not argv or argv[0] not in _BUILTINS:
266
+ return False
267
+ command, args = argv[0], argv[1:]
268
+ if command == "ls":
269
+ _ls(args, session, console)
270
+ elif command == "cd":
271
+ _cd(args, session, completer, console)
272
+ else:
273
+ _pwd(args, session, console)
274
+ return True
275
+
276
+
277
+ def _ls(args: list[str], session, console: Console) -> None:
278
+ binary = shutil.which("ls")
279
+ if binary is None:
280
+ console.print("ls is not available on this system.", style="red")
281
+ return
282
+ # ls writes the listing itself. Its own message covers a bad path.
283
+ subprocess.run([binary, *args], cwd=session.cwd, check=False)
284
+
285
+
286
+ def _cd(args: list[str], session, completer: DatasetCompleter | None, console: Console) -> None:
287
+ if len(args) > 1:
288
+ console.print("cd takes one directory.", style="red")
289
+ return
290
+ raw = args[0] if args else "~"
291
+ target = Path(raw).expanduser()
292
+ if not target.is_absolute():
293
+ target = session.cwd / target
294
+ try:
295
+ target = target.resolve()
296
+ except OSError as exc:
297
+ console.print(str(exc), style="red", markup=False)
298
+ return
299
+ if not target.is_dir():
300
+ console.print(f"not a directory: {raw}", style="red", markup=False)
301
+ return
302
+ session.cwd = target
303
+ if completer is not None:
304
+ completer.cwd = target
305
+ console.print(str(target), markup=False)
306
+
307
+
308
+ def _pwd(args: list[str], session, console: Console) -> None:
309
+ if args:
310
+ console.print("pwd takes no arguments.", style="red")
311
+ return
312
+ console.print(str(session.cwd), markup=False)
313
+
314
+
315
+ class DatasetCompleter(Completer):
316
+ """Tab-completes dataset names in one directory. Listing them does not open the files."""
317
+
318
+ def __init__(self, cwd: Path):
319
+ self.cwd = cwd
320
+
321
+ def get_completions(self, document, complete_event):
322
+ from agis.context import find_datasets
323
+
324
+ word = document.get_word_before_cursor(pattern=_FILE_WORD)
325
+ if not word:
326
+ return
327
+ prefix = word.casefold()
328
+ for path in find_datasets(self.cwd):
329
+ name = path.name
330
+ if name.casefold().startswith(prefix):
331
+ insert = f'"{name}"' if " " in name else name
332
+ yield Completion(insert, start_position=-len(word), display=name)
333
+
334
+
335
+ def _read_request(prompt) -> str:
336
+ if prompt is None:
337
+ return input("agis> ").strip()
338
+ from prompt_toolkit.formatted_text import HTML
339
+
340
+ return prompt.prompt(HTML("<b><ansicyan>agis></ansicyan></b> ")).strip()
341
+
342
+
343
+ def main() -> None:
344
+ """Entry point. A bare `agis` opens the repl; other text is a one-shot request."""
345
+ app(args=argv_for(sys.argv[1:]), prog_name="agis")
346
+
347
+
348
+ if __name__ == "__main__":
349
+ main()
agis/config.py ADDED
@@ -0,0 +1,44 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import tomllib
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ DEFAULT_MODEL = "qwen2.5:3b"
9
+
10
+
11
+ def config_path() -> Path:
12
+ base = os.environ.get("XDG_CONFIG_HOME") or Path.home() / ".config"
13
+ return Path(base) / "agis" / "config.toml"
14
+
15
+
16
+ @dataclass
17
+ class Config:
18
+ model: str = DEFAULT_MODEL
19
+ host: str | None = None
20
+ from_file: bool = False
21
+
22
+
23
+ def load() -> Config:
24
+ """Settings from the config file, overridden by AGIS_MODEL and OLLAMA_HOST."""
25
+ config = Config()
26
+ path = config_path()
27
+ if path.exists():
28
+ data = tomllib.loads(path.read_text())
29
+ config.model = data.get("model", config.model)
30
+ config.host = data.get("host") or None
31
+ config.from_file = True
32
+ config.model = os.environ.get("AGIS_MODEL", config.model)
33
+ config.host = os.environ.get("OLLAMA_HOST", config.host)
34
+ return config
35
+
36
+
37
+ def save(model: str, host: str | None = None) -> Path:
38
+ path = config_path()
39
+ path.parent.mkdir(parents=True, exist_ok=True)
40
+ lines = [f'model = "{model}"']
41
+ if host:
42
+ lines.append(f'host = "{host}"')
43
+ path.write_text("\n".join(lines) + "\n")
44
+ return path