gridpin-mcp 0.1.0__tar.gz
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.
- gridpin_mcp-0.1.0/LICENSE +202 -0
- gridpin_mcp-0.1.0/PKG-INFO +81 -0
- gridpin_mcp-0.1.0/README.md +60 -0
- gridpin_mcp-0.1.0/pyproject.toml +32 -0
- gridpin_mcp-0.1.0/setup.cfg +4 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp/__init__.py +11 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp/cli.py +53 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp/server.py +160 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp/sheets.py +167 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp/tools.py +118 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/PKG-INFO +81 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/SOURCES.txt +17 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/dependency_links.txt +1 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/entry_points.txt +3 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/requires.txt +3 -0
- gridpin_mcp-0.1.0/src/gridpin_mcp.egg-info/top_level.txt +1 -0
- gridpin_mcp-0.1.0/tests/test_server.py +73 -0
- gridpin_mcp-0.1.0/tests/test_sheets.py +124 -0
- gridpin_mcp-0.1.0/tests/test_tools.py +100 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
|
|
2
|
+
Apache License
|
|
3
|
+
Version 2.0, January 2004
|
|
4
|
+
http://www.apache.org/licenses/
|
|
5
|
+
|
|
6
|
+
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
7
|
+
|
|
8
|
+
1. Definitions.
|
|
9
|
+
|
|
10
|
+
"License" shall mean the terms and conditions for use, reproduction,
|
|
11
|
+
and distribution as defined by Sections 1 through 9 of this document.
|
|
12
|
+
|
|
13
|
+
"Licensor" shall mean the copyright owner or entity authorized by
|
|
14
|
+
the copyright owner that is granting the License.
|
|
15
|
+
|
|
16
|
+
"Legal Entity" shall mean the union of the acting entity and all
|
|
17
|
+
other entities that control, are controlled by, or are under common
|
|
18
|
+
control with that entity. For the purposes of this definition,
|
|
19
|
+
"control" means (i) the power, direct or indirect, to cause the
|
|
20
|
+
direction or management of such entity, whether by contract or
|
|
21
|
+
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
22
|
+
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
23
|
+
|
|
24
|
+
"You" (or "Your") shall mean an individual or Legal Entity
|
|
25
|
+
exercising permissions granted by this License.
|
|
26
|
+
|
|
27
|
+
"Source" form shall mean the preferred form for making modifications,
|
|
28
|
+
including but not limited to software source code, documentation
|
|
29
|
+
source, and configuration files.
|
|
30
|
+
|
|
31
|
+
"Object" form shall mean any form resulting from mechanical
|
|
32
|
+
transformation or translation of a Source form, including but
|
|
33
|
+
not limited to compiled object code, generated documentation,
|
|
34
|
+
and conversions to other media types.
|
|
35
|
+
|
|
36
|
+
"Work" shall mean the work of authorship, whether in Source or
|
|
37
|
+
Object form, made available under the License, as indicated by a
|
|
38
|
+
copyright notice that is included in or attached to the work
|
|
39
|
+
(an example is provided in the Appendix below).
|
|
40
|
+
|
|
41
|
+
"Derivative Works" shall mean any work, whether in Source or Object
|
|
42
|
+
form, that is based on (or derived from) the Work and for which the
|
|
43
|
+
editorial revisions, annotations, elaborations, or other modifications
|
|
44
|
+
represent, as a whole, an original work of authorship. For the purposes
|
|
45
|
+
of this License, Derivative Works shall not include works that remain
|
|
46
|
+
separable from, or merely link (or bind by name) to the interfaces of,
|
|
47
|
+
the Work and Derivative Works thereof.
|
|
48
|
+
|
|
49
|
+
"Contribution" shall mean any work of authorship, including
|
|
50
|
+
the original version of the Work and any modifications or additions
|
|
51
|
+
to that Work or Derivative Works thereof, that is intentionally
|
|
52
|
+
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
53
|
+
or by an individual or Legal Entity authorized to submit on behalf of
|
|
54
|
+
the copyright owner. For the purposes of this definition, "submitted"
|
|
55
|
+
means any form of electronic, verbal, or written communication sent
|
|
56
|
+
to the Licensor or its representatives, including but not limited to
|
|
57
|
+
communication on electronic mailing lists, source code control systems,
|
|
58
|
+
and issue tracking systems that are managed by, or on behalf of, the
|
|
59
|
+
Licensor for the purpose of discussing and improving the Work, but
|
|
60
|
+
excluding communication that is conspicuously marked or otherwise
|
|
61
|
+
designated in writing by the copyright owner as "Not a Contribution."
|
|
62
|
+
|
|
63
|
+
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
64
|
+
on behalf of whom a Contribution has been received by Licensor and
|
|
65
|
+
subsequently incorporated within the Work.
|
|
66
|
+
|
|
67
|
+
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
68
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
69
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
70
|
+
copyright license to reproduce, prepare Derivative Works of,
|
|
71
|
+
publicly display, publicly perform, sublicense, and distribute the
|
|
72
|
+
Work and such Derivative Works in Source or Object form.
|
|
73
|
+
|
|
74
|
+
3. Grant of Patent License. Subject to the terms and conditions of
|
|
75
|
+
this License, each Contributor hereby grants to You a perpetual,
|
|
76
|
+
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
77
|
+
(except as stated in this section) patent license to make, have made,
|
|
78
|
+
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
79
|
+
where such license applies only to those patent claims licensable
|
|
80
|
+
by such Contributor that are necessarily infringed by their
|
|
81
|
+
Contribution(s) alone or by combination of their Contribution(s)
|
|
82
|
+
with the Work to which such Contribution(s) was submitted. If You
|
|
83
|
+
institute patent litigation against any entity (including a
|
|
84
|
+
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
85
|
+
or a Contribution incorporated within the Work constitutes direct
|
|
86
|
+
or contributory patent infringement, then any patent licenses
|
|
87
|
+
granted to You under this License for that Work shall terminate
|
|
88
|
+
as of the date such litigation is filed.
|
|
89
|
+
|
|
90
|
+
4. Redistribution. You may reproduce and distribute copies of the
|
|
91
|
+
Work or Derivative Works thereof in any medium, with or without
|
|
92
|
+
modifications, and in Source or Object form, provided that You
|
|
93
|
+
meet the following conditions:
|
|
94
|
+
|
|
95
|
+
(a) You must give any other recipients of the Work or
|
|
96
|
+
Derivative Works a copy of this License; and
|
|
97
|
+
|
|
98
|
+
(b) You must cause any modified files to carry prominent notices
|
|
99
|
+
stating that You changed the files; and
|
|
100
|
+
|
|
101
|
+
(c) You must retain, in the Source form of any Derivative Works
|
|
102
|
+
that You distribute, all copyright, patent, trademark, and
|
|
103
|
+
attribution notices from the Source form of the Work,
|
|
104
|
+
excluding those notices that do not pertain to any part of
|
|
105
|
+
the Derivative Works; and
|
|
106
|
+
|
|
107
|
+
(d) If the Work includes a "NOTICE" text file as part of its
|
|
108
|
+
distribution, then any Derivative Works that You distribute must
|
|
109
|
+
include a readable copy of the attribution notices contained
|
|
110
|
+
within such NOTICE file, excluding those notices that do not
|
|
111
|
+
pertain to any part of the Derivative Works, in at least one
|
|
112
|
+
of the following places: within a NOTICE text file distributed
|
|
113
|
+
as part of the Derivative Works; within the Source form or
|
|
114
|
+
documentation, if provided along with the Derivative Works; or,
|
|
115
|
+
within a display generated by the Derivative Works, if and
|
|
116
|
+
wherever such third-party notices normally appear. The contents
|
|
117
|
+
of the NOTICE file are for informational purposes only and
|
|
118
|
+
do not modify the License. You may add Your own attribution
|
|
119
|
+
notices within Derivative Works that You distribute, alongside
|
|
120
|
+
or as an addendum to the NOTICE text from the Work, provided
|
|
121
|
+
that such additional attribution notices cannot be construed
|
|
122
|
+
as modifying the License.
|
|
123
|
+
|
|
124
|
+
You may add Your own copyright statement to Your modifications and
|
|
125
|
+
may provide additional or different license terms and conditions
|
|
126
|
+
for use, reproduction, or distribution of Your modifications, or
|
|
127
|
+
for any such Derivative Works as a whole, provided Your use,
|
|
128
|
+
reproduction, and distribution of the Work otherwise complies with
|
|
129
|
+
the conditions stated in this License.
|
|
130
|
+
|
|
131
|
+
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
132
|
+
any Contribution intentionally submitted for inclusion in the Work
|
|
133
|
+
by You to the Licensor shall be under the terms and conditions of
|
|
134
|
+
this License, without any additional terms or conditions.
|
|
135
|
+
Notwithstanding the above, nothing herein shall supersede or modify
|
|
136
|
+
the terms of any separate license agreement you may have executed
|
|
137
|
+
with Licensor regarding such Contributions.
|
|
138
|
+
|
|
139
|
+
6. Trademarks. This License does not grant permission to use the trade
|
|
140
|
+
names, trademarks, service marks, or product names of the Licensor,
|
|
141
|
+
except as required for reasonable and customary use in describing the
|
|
142
|
+
origin of the Work and reproducing the content of the NOTICE file.
|
|
143
|
+
|
|
144
|
+
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
145
|
+
agreed to in writing, Licensor provides the Work (and each
|
|
146
|
+
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
147
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
148
|
+
implied, including, without limitation, any warranties or conditions
|
|
149
|
+
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
150
|
+
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
151
|
+
appropriateness of using or redistributing the Work and assume any
|
|
152
|
+
risks associated with Your exercise of permissions under this License.
|
|
153
|
+
|
|
154
|
+
8. Limitation of Liability. In no event and under no legal theory,
|
|
155
|
+
whether in tort (including negligence), contract, or otherwise,
|
|
156
|
+
unless required by applicable law (such as deliberate and grossly
|
|
157
|
+
negligent acts) or agreed to in writing, shall any Contributor be
|
|
158
|
+
liable to You for damages, including any direct, indirect, special,
|
|
159
|
+
incidental, or consequential damages of any character arising as a
|
|
160
|
+
result of this License or out of the use or inability to use the
|
|
161
|
+
Work (including but not limited to damages for loss of goodwill,
|
|
162
|
+
work stoppage, computer failure or malfunction, or any and all
|
|
163
|
+
other commercial damages or losses), even if such Contributor
|
|
164
|
+
has been advised of the possibility of such damages.
|
|
165
|
+
|
|
166
|
+
9. Accepting Warranty or Additional Liability. While redistributing
|
|
167
|
+
the Work or Derivative Works thereof, You may choose to offer,
|
|
168
|
+
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
169
|
+
or other liability obligations and/or rights consistent with this
|
|
170
|
+
License. However, in accepting such obligations, You may act only
|
|
171
|
+
on Your own behalf and on Your sole responsibility, not on behalf
|
|
172
|
+
of any other Contributor, and only if You agree to indemnify,
|
|
173
|
+
defend, and hold each Contributor harmless for any liability
|
|
174
|
+
incurred by, or claims asserted against, such Contributor by reason
|
|
175
|
+
of your accepting any such warranty or additional liability.
|
|
176
|
+
|
|
177
|
+
END OF TERMS AND CONDITIONS
|
|
178
|
+
|
|
179
|
+
APPENDIX: How to apply the Apache License to your work.
|
|
180
|
+
|
|
181
|
+
To apply the Apache License to your work, attach the following
|
|
182
|
+
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
183
|
+
replaced with your own identifying information. (Don't include
|
|
184
|
+
the brackets!) The text should be enclosed in the appropriate
|
|
185
|
+
comment syntax for the file format. We also recommend that a
|
|
186
|
+
file or class name and description of purpose be included on the
|
|
187
|
+
same "printed page" as the copyright notice for easier
|
|
188
|
+
identification within third-party archives.
|
|
189
|
+
|
|
190
|
+
Copyright [yyyy] [name of copyright owner]
|
|
191
|
+
|
|
192
|
+
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
|
+
you may not use this file except in compliance with the License.
|
|
194
|
+
You may obtain a copy of the License at
|
|
195
|
+
|
|
196
|
+
http://www.apache.org/licenses/LICENSE-2.0
|
|
197
|
+
|
|
198
|
+
Unless required by applicable law or agreed to in writing, software
|
|
199
|
+
distributed under the License is distributed on an "AS IS" BASIS,
|
|
200
|
+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
201
|
+
See the License for the specific language governing permissions and
|
|
202
|
+
limitations under the License.
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridpin-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for offline geocoding with GridPin: addresses to coordinates and back from one local file per country, no API key
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://gridpin.dev
|
|
7
|
+
Project-URL: Documentation, https://gridpin.dev/docs
|
|
8
|
+
Project-URL: Repository, https://github.com/gridpin/gridpin
|
|
9
|
+
Project-URL: Issues, https://github.com/gridpin/gridpin/issues
|
|
10
|
+
Keywords: mcp,geocoding,geocoder,offline,reverse-geocoding,gis,overture,ai-agents
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: gridpin<0.5,>=0.4.0
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Requires-Dist: typing-extensions>=4.13
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# gridpin-mcp — offline geocoding for AI agents
|
|
23
|
+
|
|
24
|
+
<!-- mcp-name: io.github.gridpin/gridpin-mcp -->
|
|
25
|
+
|
|
26
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an agent geocode addresses with
|
|
27
|
+
[GridPin](https://gridpin.dev): one memory-mapped file per country, read on your machine.
|
|
28
|
+
No API key, no rate limit, and no query leaves the machine. The only network step is
|
|
29
|
+
downloading a country sheet once, verified against the signed GridPin release.
|
|
30
|
+
|
|
31
|
+
Countries (release v0.4.0): Brazil, France, Germany, Italy, Netherlands, Serbia, Spain.
|
|
32
|
+
|
|
33
|
+
## Add it to an agent
|
|
34
|
+
|
|
35
|
+
Claude Code:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ claude mcp add gridpin -- uvx gridpin-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Any client that reads an `mcpServers` config (Claude Desktop, Cursor, ...):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "mcpServers": { "gridpin": { "command": "uvx", "args": ["gridpin-mcp"] } } }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
| Tool | What it does |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `list_countries` | covered countries, sheet sizes, which are downloaded |
|
|
52
|
+
| `download_sheet` | fetch one country sheet (32-677 MB) once, verify its SHA-256; the only tool that uses the network |
|
|
53
|
+
| `geocode` | address text -> ranked candidates with `lat`, `lon`, `precision`, `confidence` |
|
|
54
|
+
| `reverse` | coordinates -> nearest indexed addresses with `distance_m` |
|
|
55
|
+
| `batch` | up to 1000 addresses of one country per call, in input order |
|
|
56
|
+
|
|
57
|
+
Results are the same as the GridPin CLI, Python and DuckDB return. Treat `street` or
|
|
58
|
+
`city` precision as approximate.
|
|
59
|
+
|
|
60
|
+
## Download a sheet without MCP
|
|
61
|
+
|
|
62
|
+
```console
|
|
63
|
+
$ pip install gridpin-mcp
|
|
64
|
+
$ gridpin-fetch fr # prints the path, e.g. ~/.cache/gridpin/v0.4.0/france.bin
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import gridpin
|
|
69
|
+
from gridpin_mcp import sheets
|
|
70
|
+
|
|
71
|
+
path = sheets.download("fr") # no-op when already downloaded
|
|
72
|
+
gridpin.Geocoder(str(path)).geocode("1 rue de la Paix, Paris", 1)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Sheets go to `$GRIDPIN_HOME`, else `$XDG_CACHE_HOME/gridpin`, else `~/.cache/gridpin`.
|
|
76
|
+
Sizes and SHA-256 of every sheet are pinned in the package from the signed release
|
|
77
|
+
attestation; a download that does not match is discarded.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0. Sheet data: see [attributions](https://github.com/gridpin/gridpin/blob/main/ATTRIBUTIONS.md).
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# gridpin-mcp — offline geocoding for AI agents
|
|
2
|
+
|
|
3
|
+
<!-- mcp-name: io.github.gridpin/gridpin-mcp -->
|
|
4
|
+
|
|
5
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an agent geocode addresses with
|
|
6
|
+
[GridPin](https://gridpin.dev): one memory-mapped file per country, read on your machine.
|
|
7
|
+
No API key, no rate limit, and no query leaves the machine. The only network step is
|
|
8
|
+
downloading a country sheet once, verified against the signed GridPin release.
|
|
9
|
+
|
|
10
|
+
Countries (release v0.4.0): Brazil, France, Germany, Italy, Netherlands, Serbia, Spain.
|
|
11
|
+
|
|
12
|
+
## Add it to an agent
|
|
13
|
+
|
|
14
|
+
Claude Code:
|
|
15
|
+
|
|
16
|
+
```console
|
|
17
|
+
$ claude mcp add gridpin -- uvx gridpin-mcp
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Any client that reads an `mcpServers` config (Claude Desktop, Cursor, ...):
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{ "mcpServers": { "gridpin": { "command": "uvx", "args": ["gridpin-mcp"] } } }
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Tools
|
|
27
|
+
|
|
28
|
+
| Tool | What it does |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `list_countries` | covered countries, sheet sizes, which are downloaded |
|
|
31
|
+
| `download_sheet` | fetch one country sheet (32-677 MB) once, verify its SHA-256; the only tool that uses the network |
|
|
32
|
+
| `geocode` | address text -> ranked candidates with `lat`, `lon`, `precision`, `confidence` |
|
|
33
|
+
| `reverse` | coordinates -> nearest indexed addresses with `distance_m` |
|
|
34
|
+
| `batch` | up to 1000 addresses of one country per call, in input order |
|
|
35
|
+
|
|
36
|
+
Results are the same as the GridPin CLI, Python and DuckDB return. Treat `street` or
|
|
37
|
+
`city` precision as approximate.
|
|
38
|
+
|
|
39
|
+
## Download a sheet without MCP
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ pip install gridpin-mcp
|
|
43
|
+
$ gridpin-fetch fr # prints the path, e.g. ~/.cache/gridpin/v0.4.0/france.bin
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import gridpin
|
|
48
|
+
from gridpin_mcp import sheets
|
|
49
|
+
|
|
50
|
+
path = sheets.download("fr") # no-op when already downloaded
|
|
51
|
+
gridpin.Geocoder(str(path)).geocode("1 rue de la Paix, Paris", 1)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Sheets go to `$GRIDPIN_HOME`, else `$XDG_CACHE_HOME/gridpin`, else `~/.cache/gridpin`.
|
|
55
|
+
Sizes and SHA-256 of every sheet are pinned in the package from the signed release
|
|
56
|
+
attestation; a download that does not match is discarded.
|
|
57
|
+
|
|
58
|
+
## License
|
|
59
|
+
|
|
60
|
+
Apache-2.0. Sheet data: see [attributions](https://github.com/gridpin/gridpin/blob/main/ATTRIBUTIONS.md).
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=77"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gridpin-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP server for offline geocoding with GridPin: addresses to coordinates and back from one local file per country, no API key"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
keywords = ["mcp", "geocoding", "geocoder", "offline", "reverse-geocoding", "gis", "overture", "ai-agents"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Topic :: Scientific/Engineering :: GIS",
|
|
18
|
+
]
|
|
19
|
+
dependencies = ["gridpin>=0.4.0,<0.5", "mcp>=2.3,<3", "typing-extensions>=4.13"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://gridpin.dev"
|
|
23
|
+
Documentation = "https://gridpin.dev/docs"
|
|
24
|
+
Repository = "https://github.com/gridpin/gridpin"
|
|
25
|
+
Issues = "https://github.com/gridpin/gridpin/issues"
|
|
26
|
+
|
|
27
|
+
[project.scripts]
|
|
28
|
+
gridpin-mcp = "gridpin_mcp.cli:serve"
|
|
29
|
+
gridpin-fetch = "gridpin_mcp.cli:fetch_main"
|
|
30
|
+
|
|
31
|
+
[tool.setuptools.packages.find]
|
|
32
|
+
where = ["src"]
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""GridPin for AI agents: an MCP server and one-command sheet downloads.
|
|
2
|
+
|
|
3
|
+
Python use without MCP:
|
|
4
|
+
|
|
5
|
+
import gridpin
|
|
6
|
+
from gridpin_mcp import sheets
|
|
7
|
+
path = sheets.download("fr") # once; verified against the signed release
|
|
8
|
+
gridpin.Geocoder(str(path)).geocode("1 rue de la Paix, Paris", 1)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Command line: `gridpin-fetch` downloads sheets, `gridpin-mcp` starts the MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import sheets
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _progress(done: int, total: int) -> None:
|
|
13
|
+
sys.stderr.write(f"\r {done / 1e6:7.1f} / {total / 1e6:.1f} MB")
|
|
14
|
+
if done >= total:
|
|
15
|
+
sys.stderr.write("\n")
|
|
16
|
+
sys.stderr.flush()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fetch(argv: Optional[List[str]] = None) -> int:
|
|
20
|
+
parser = argparse.ArgumentParser(
|
|
21
|
+
prog="gridpin-fetch",
|
|
22
|
+
description=f"Download GridPin country sheets ({sheets.RELEASE}) and verify them "
|
|
23
|
+
"against the signed release. Prints the path of each sheet.",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("countries", nargs="*", help="ISO code or English name, e.g. fr de Spain")
|
|
26
|
+
parser.add_argument("--list", action="store_true", help="list countries and exit")
|
|
27
|
+
args = parser.parse_args(argv)
|
|
28
|
+
if args.list or not args.countries:
|
|
29
|
+
for s in sheets.SHEETS.values():
|
|
30
|
+
state = "downloaded" if sheets.is_downloaded(s) else "not downloaded"
|
|
31
|
+
print(f"{s.code} {s.name:<12} {s.size / 1e6:6.0f} MB {state}")
|
|
32
|
+
print(f"sheets directory: {sheets.home() / sheets.RELEASE}")
|
|
33
|
+
return 0 if args.list else 2
|
|
34
|
+
status = 0
|
|
35
|
+
for country in args.countries:
|
|
36
|
+
try:
|
|
37
|
+
print(sheets.download(country, progress=_progress))
|
|
38
|
+
except sheets.SheetError as exc:
|
|
39
|
+
print(f"gridpin-fetch: {exc}", file=sys.stderr)
|
|
40
|
+
status = 1
|
|
41
|
+
return status
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def serve() -> None:
|
|
45
|
+
try:
|
|
46
|
+
from .server import main
|
|
47
|
+
except ImportError as exc:
|
|
48
|
+
sys.exit(f"gridpin-mcp: the MCP SDK is missing ({exc}); run `pip install gridpin-mcp`")
|
|
49
|
+
main()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_main() -> None:
|
|
53
|
+
sys.exit(fetch())
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""MCP server: GridPin offline geocoding as tools for AI agents (stdio transport)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from typing_extensions import NotRequired, TypedDict
|
|
9
|
+
|
|
10
|
+
from mcp.server.mcpserver import MCPServer
|
|
11
|
+
from mcp.server.mcpserver.exceptions import ToolError as McpToolError
|
|
12
|
+
from mcp.types import ToolAnnotations
|
|
13
|
+
|
|
14
|
+
from . import __version__, sheets, tools
|
|
15
|
+
|
|
16
|
+
INSTRUCTIONS = f"""\
|
|
17
|
+
GridPin geocodes addresses offline from one data file per country ("sheet").
|
|
18
|
+
Countries in release {sheets.RELEASE}: {", ".join(f"{s.code} ({s.name})" for s in sheets.SHEETS.values())}.
|
|
19
|
+
Workflow: call list_countries; if the country is not downloaded, call download_sheet once
|
|
20
|
+
(32-677 MB, verified against the signed release); then geocode, reverse or batch.
|
|
21
|
+
No API key, no rate limit, and no query leaves the machine. Results carry precision
|
|
22
|
+
(house, interp, street, city, ...) and confidence; treat street- or city-level results as approximate.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
READ_ONLY = ToolAnnotations(read_only_hint=True, open_world_hint=False)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Hit(TypedDict):
|
|
29
|
+
"""One candidate, the same fields the CLI, Python and DuckDB return."""
|
|
30
|
+
|
|
31
|
+
lat: float
|
|
32
|
+
lon: float
|
|
33
|
+
precision: str
|
|
34
|
+
score: float
|
|
35
|
+
confidence: float
|
|
36
|
+
street: str
|
|
37
|
+
commune: str
|
|
38
|
+
postcode: str
|
|
39
|
+
housenumber: NotRequired[str]
|
|
40
|
+
region: NotRequired[str]
|
|
41
|
+
flags: NotRequired[List[str]]
|
|
42
|
+
distance_m: NotRequired[float]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GeocodeOut(TypedDict):
|
|
46
|
+
country: str
|
|
47
|
+
query: str
|
|
48
|
+
results: List[Hit]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ReverseOut(TypedDict):
|
|
52
|
+
country: str
|
|
53
|
+
lat: float
|
|
54
|
+
lon: float
|
|
55
|
+
results: List[Hit]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class BatchRow(TypedDict):
|
|
59
|
+
query: str
|
|
60
|
+
results: List[Hit]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class BatchOut(TypedDict):
|
|
64
|
+
country: str
|
|
65
|
+
results: List[BatchRow]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class Country(TypedDict):
|
|
69
|
+
code: str
|
|
70
|
+
name: str
|
|
71
|
+
size_mb: int
|
|
72
|
+
downloaded: bool
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class CountriesOut(TypedDict):
|
|
76
|
+
release: str
|
|
77
|
+
home: str
|
|
78
|
+
countries: List[Country]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class DownloadOut(TypedDict):
|
|
82
|
+
country: str
|
|
83
|
+
path: str
|
|
84
|
+
size_mb: int
|
|
85
|
+
already_downloaded: bool
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _agent_errors(fn):
|
|
89
|
+
"""Turn an anticipated tools.ToolError into the SDK error the model can read."""
|
|
90
|
+
|
|
91
|
+
@functools.wraps(fn)
|
|
92
|
+
def wrapper(*args, **kwargs):
|
|
93
|
+
try:
|
|
94
|
+
return fn(*args, **kwargs)
|
|
95
|
+
except tools.ToolError as exc:
|
|
96
|
+
raise McpToolError(str(exc)) from exc
|
|
97
|
+
|
|
98
|
+
return wrapper
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_server(session: Optional[tools.Session] = None) -> MCPServer:
|
|
102
|
+
session = session or tools.Session()
|
|
103
|
+
server = MCPServer(
|
|
104
|
+
name="gridpin",
|
|
105
|
+
title="GridPin offline geocoder",
|
|
106
|
+
instructions=INSTRUCTIONS,
|
|
107
|
+
website_url="https://gridpin.dev",
|
|
108
|
+
version=__version__,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
@server.tool(annotations=READ_ONLY)
|
|
112
|
+
@_agent_errors
|
|
113
|
+
def list_countries() -> CountriesOut:
|
|
114
|
+
"""List covered countries, their sheet sizes and whether each sheet is downloaded."""
|
|
115
|
+
return tools.countries()
|
|
116
|
+
|
|
117
|
+
@server.tool(
|
|
118
|
+
annotations=ToolAnnotations(
|
|
119
|
+
read_only_hint=False, destructive_hint=False, idempotent_hint=True, open_world_hint=True
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
@_agent_errors
|
|
123
|
+
def download_sheet(country: str) -> DownloadOut:
|
|
124
|
+
"""Download one country sheet from dl.gridpin.dev and verify its SHA-256 against the
|
|
125
|
+
signed release. Needed once per country; the only tool that uses the network.
|
|
126
|
+
|
|
127
|
+
country: ISO code or English name, e.g. "fr" or "France".
|
|
128
|
+
"""
|
|
129
|
+
return tools.download_sheet(country)
|
|
130
|
+
|
|
131
|
+
@server.tool(annotations=READ_ONLY)
|
|
132
|
+
@_agent_errors
|
|
133
|
+
def geocode(query: str, country: str, k: int = 1) -> GeocodeOut:
|
|
134
|
+
"""Geocode one address or place: text -> coordinates, offline.
|
|
135
|
+
|
|
136
|
+
query: the address as a person would write it, e.g. "1 rue de la Paix, Paris".
|
|
137
|
+
country: ISO code or English name of the country the address is in.
|
|
138
|
+
k: number of ranked candidates, 1-10.
|
|
139
|
+
"""
|
|
140
|
+
return session.geocode(query, country, k)
|
|
141
|
+
|
|
142
|
+
@server.tool(annotations=READ_ONLY)
|
|
143
|
+
@_agent_errors
|
|
144
|
+
def reverse(lat: float, lon: float, country: str, k: int = 1) -> ReverseOut:
|
|
145
|
+
"""Reverse geocode: coordinates -> nearest indexed addresses, offline.
|
|
146
|
+
Approximate: each result carries distance_m from the given point.
|
|
147
|
+
"""
|
|
148
|
+
return session.reverse(lat, lon, country, k)
|
|
149
|
+
|
|
150
|
+
@server.tool(annotations=READ_ONLY)
|
|
151
|
+
@_agent_errors
|
|
152
|
+
def batch(queries: List[str], country: str, k: int = 1) -> BatchOut:
|
|
153
|
+
"""Geocode up to 1000 addresses of one country in a single call, in input order."""
|
|
154
|
+
return session.batch(queries, country, k)
|
|
155
|
+
|
|
156
|
+
return server
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def main() -> None:
|
|
160
|
+
build_server().run("stdio")
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Country sheets: where they live, how to fetch one, how a download is verified.
|
|
2
|
+
|
|
3
|
+
A sheet is a single memory-mapped file per country. This module is the only place in
|
|
4
|
+
gridpin-mcp that touches the network, and only when you ask for a download. Geocoding
|
|
5
|
+
itself never does.
|
|
6
|
+
|
|
7
|
+
Every sheet of a release is pinned below by size and SHA-256. The values are copied from
|
|
8
|
+
the signed release attestation (``https://dl.gridpin.dev/<release>/attestation.json``,
|
|
9
|
+
SSH signature ``attestation.json.sig``, signer key ``gridpin-release-signers`` in the
|
|
10
|
+
GridPin repository). A download that does not match is discarded, never opened.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import tempfile
|
|
19
|
+
import urllib.request
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Callable, Optional
|
|
23
|
+
|
|
24
|
+
RELEASE = "v0.4.0"
|
|
25
|
+
BASE_URL = "https://dl.gridpin.dev"
|
|
26
|
+
# SHA-256 of attestation.json for RELEASE; the pins below are taken from that file.
|
|
27
|
+
ATTESTATION_SHA256 = "feba5aca7198d1dbc78b215ce52b260499387d86872e29d7088098df4abb98c9"
|
|
28
|
+
|
|
29
|
+
CHUNK = 1 << 20
|
|
30
|
+
# Free space required beyond the sheet itself, so a download never fills the disk.
|
|
31
|
+
DISK_MARGIN = 512 << 20
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Sheet:
|
|
36
|
+
code: str
|
|
37
|
+
name: str
|
|
38
|
+
file: str
|
|
39
|
+
size: int
|
|
40
|
+
sha256: str
|
|
41
|
+
|
|
42
|
+
@property
|
|
43
|
+
def url(self) -> str:
|
|
44
|
+
return f"{BASE_URL}/{RELEASE}/{self.file}"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SHEETS = {
|
|
48
|
+
s.code: s
|
|
49
|
+
for s in (
|
|
50
|
+
Sheet("br", "Brazil", "brazil.bin", 676851417,
|
|
51
|
+
"016e102debb05aafad0ae409c3a2acec478b60bb48b74d03210b80dc52d42a6d"),
|
|
52
|
+
Sheet("de", "Germany", "germany.bin", 213315143,
|
|
53
|
+
"c0ac3d0cbf3dbd480bff6aebd717e2df96e9f0a2eb4e83aeffde736fa42b18af"),
|
|
54
|
+
Sheet("es", "Spain", "spain.bin", 185426996,
|
|
55
|
+
"c671b9a46089e32580189ee3c5a65e024779691a50ab26e77f5639023994819b"),
|
|
56
|
+
Sheet("fr", "France", "france.bin", 364964425,
|
|
57
|
+
"f7278398c1a63308ea3dc69e5cda2f779250716fc231b2122d6ee890da237839"),
|
|
58
|
+
Sheet("it", "Italy", "italy.bin", 246502930,
|
|
59
|
+
"37346d884e551189e91f0453d4aad7aafe211430e62b1e9054141081488b021c"),
|
|
60
|
+
Sheet("nl", "Netherlands", "netherlands.bin", 103234504,
|
|
61
|
+
"d5205f8e323eeda309f3d411a3efcd45c259005d2da25fef33998bf9ca9d28e7"),
|
|
62
|
+
Sheet("rs", "Serbia", "serbia.bin", 31998043,
|
|
63
|
+
"9e358bd6a940b290e01cd52081e476e2799513f16dd934dc6a5bc89ba4fb5388"),
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class SheetError(Exception):
|
|
69
|
+
"""A problem the caller can act on; the message says what to do next."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve(country: str) -> Sheet:
|
|
73
|
+
"""Map a country code or English name ("fr", "FR", "France") to its sheet."""
|
|
74
|
+
key = (country or "").strip().lower()
|
|
75
|
+
for sheet in SHEETS.values():
|
|
76
|
+
if key in (sheet.code, sheet.name.lower()):
|
|
77
|
+
return sheet
|
|
78
|
+
known = ", ".join(f"{s.code} ({s.name})" for s in SHEETS.values())
|
|
79
|
+
raise SheetError(f"unknown country {country!r}; available: {known}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def home() -> Path:
|
|
83
|
+
"""Directory that holds downloaded sheets.
|
|
84
|
+
|
|
85
|
+
``GRIDPIN_HOME`` wins; otherwise ``$XDG_CACHE_HOME/gridpin`` or ``~/.cache/gridpin``.
|
|
86
|
+
Sheets of different releases live in separate subdirectories.
|
|
87
|
+
"""
|
|
88
|
+
explicit = os.environ.get("GRIDPIN_HOME")
|
|
89
|
+
if explicit:
|
|
90
|
+
return Path(explicit).expanduser()
|
|
91
|
+
cache = os.environ.get("XDG_CACHE_HOME")
|
|
92
|
+
base = Path(cache).expanduser() if cache else Path.home() / ".cache"
|
|
93
|
+
return base / "gridpin"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def local_path(sheet: Sheet) -> Path:
|
|
97
|
+
return home() / RELEASE / sheet.file
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def is_downloaded(sheet: Sheet) -> bool:
|
|
101
|
+
path = local_path(sheet)
|
|
102
|
+
return path.is_file() and path.stat().st_size == sheet.size
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def sheet_path(country: str) -> Path:
|
|
106
|
+
"""Path of a downloaded sheet, or an error that names the command to fetch it."""
|
|
107
|
+
sheet = resolve(country)
|
|
108
|
+
if not is_downloaded(sheet):
|
|
109
|
+
raise SheetError(
|
|
110
|
+
f"the {sheet.name} sheet is not downloaded ({sheet.size / 1e6:.0f} MB); run "
|
|
111
|
+
f"`gridpin-fetch {sheet.code}` or call the download_sheet tool"
|
|
112
|
+
)
|
|
113
|
+
return local_path(sheet)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
Opener = Callable[[str], "urllib.response.addinfourl"]
|
|
117
|
+
Progress = Callable[[int, int], None]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _open(url: str):
|
|
121
|
+
request = urllib.request.Request(url, headers={"User-Agent": f"gridpin-mcp ({RELEASE})"})
|
|
122
|
+
return urllib.request.urlopen(request, timeout=60)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def download(country: str, opener: Opener = _open, progress: Optional[Progress] = None) -> Path:
|
|
126
|
+
"""Fetch one sheet into ``home()``, verify size and SHA-256, then move it into place.
|
|
127
|
+
|
|
128
|
+
Already present with the pinned size: returned as is, nothing is fetched. The partial
|
|
129
|
+
file is removed on any failure, so a broken download is never mistaken for a sheet.
|
|
130
|
+
"""
|
|
131
|
+
sheet = resolve(country)
|
|
132
|
+
target = local_path(sheet)
|
|
133
|
+
if is_downloaded(sheet):
|
|
134
|
+
return target
|
|
135
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
136
|
+
free = shutil.disk_usage(target.parent).free
|
|
137
|
+
if free < sheet.size + DISK_MARGIN:
|
|
138
|
+
raise SheetError(
|
|
139
|
+
f"not enough disk space in {target.parent}: the {sheet.name} sheet needs "
|
|
140
|
+
f"{(sheet.size + DISK_MARGIN) / 1e9:.1f} GB, {free / 1e9:.1f} GB free; "
|
|
141
|
+
f"set GRIDPIN_HOME to another directory"
|
|
142
|
+
)
|
|
143
|
+
digest = hashlib.sha256()
|
|
144
|
+
received = 0
|
|
145
|
+
fd, tmp = tempfile.mkstemp(prefix=sheet.file + ".", suffix=".part", dir=target.parent)
|
|
146
|
+
try:
|
|
147
|
+
with os.fdopen(fd, "wb") as out, opener(sheet.url) as response:
|
|
148
|
+
while True:
|
|
149
|
+
block = response.read(CHUNK)
|
|
150
|
+
if not block:
|
|
151
|
+
break
|
|
152
|
+
received += len(block)
|
|
153
|
+
if received > sheet.size:
|
|
154
|
+
raise SheetError(f"{sheet.url}: server sent more than {sheet.size} bytes")
|
|
155
|
+
digest.update(block)
|
|
156
|
+
out.write(block)
|
|
157
|
+
if progress:
|
|
158
|
+
progress(received, sheet.size)
|
|
159
|
+
if received != sheet.size:
|
|
160
|
+
raise SheetError(f"{sheet.url}: got {received} bytes, expected {sheet.size}; retry")
|
|
161
|
+
if digest.hexdigest() != sheet.sha256:
|
|
162
|
+
raise SheetError(f"{sheet.url}: SHA-256 does not match the signed release; not used")
|
|
163
|
+
os.replace(tmp, target)
|
|
164
|
+
except BaseException:
|
|
165
|
+
Path(tmp).unlink(missing_ok=True)
|
|
166
|
+
raise
|
|
167
|
+
return target
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Geocoding tools over local sheets, shaped for agents: plain JSON in, plain JSON out.
|
|
2
|
+
|
|
3
|
+
These functions do not depend on the MCP SDK, so they are usable (and tested) on their own.
|
|
4
|
+
The server module only wires them to MCP tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from . import sheets
|
|
13
|
+
|
|
14
|
+
MAX_BATCH = 1000
|
|
15
|
+
MAX_K = 10
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolError(Exception):
|
|
19
|
+
"""An error the agent can act on; the message names the next step."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _engine():
|
|
23
|
+
try:
|
|
24
|
+
import gridpin # the Rust engine wheel
|
|
25
|
+
except ImportError as exc: # pragma: no cover - exercised only without the wheel
|
|
26
|
+
raise ToolError("the gridpin engine is not installed; run `pip install gridpin`") from exc
|
|
27
|
+
return gridpin
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _check_k(k: int) -> int:
|
|
31
|
+
if not isinstance(k, int) or k < 1 or k > MAX_K:
|
|
32
|
+
raise ToolError(f"k must be an integer from 1 to {MAX_K}, got {k!r}")
|
|
33
|
+
return k
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Session:
|
|
37
|
+
"""Opened sheets, one engine handle per country, kept for the life of the server."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, open_sheet: Optional[Callable[[str], Path]] = None):
|
|
40
|
+
self._open_sheet = open_sheet or sheets.sheet_path
|
|
41
|
+
self._geocoders: Dict[str, Any] = {}
|
|
42
|
+
|
|
43
|
+
def geocoder(self, country: str):
|
|
44
|
+
try:
|
|
45
|
+
code = sheets.resolve(country).code
|
|
46
|
+
if code not in self._geocoders:
|
|
47
|
+
path = self._open_sheet(code)
|
|
48
|
+
self._geocoders[code] = _engine().Geocoder(str(path))
|
|
49
|
+
except sheets.SheetError as exc:
|
|
50
|
+
raise ToolError(str(exc)) from exc
|
|
51
|
+
return code, self._geocoders[code]
|
|
52
|
+
|
|
53
|
+
def geocode(self, query: str, country: str, k: int = 1) -> Dict[str, Any]:
|
|
54
|
+
"""Address or place text -> ranked candidates with coordinates."""
|
|
55
|
+
if not isinstance(query, str) or not query.strip():
|
|
56
|
+
raise ToolError("query must be a non-empty address string")
|
|
57
|
+
code, geo = self.geocoder(country)
|
|
58
|
+
return {"country": code, "query": query, "results": list(geo.geocode(query, _check_k(k)))}
|
|
59
|
+
|
|
60
|
+
def reverse(self, lat: float, lon: float, country: str, k: int = 1) -> Dict[str, Any]:
|
|
61
|
+
"""Coordinates -> nearest indexed addresses; each result carries distance_m."""
|
|
62
|
+
code, geo = self.geocoder(country)
|
|
63
|
+
try:
|
|
64
|
+
hits = geo.reverse(float(lat), float(lon), _check_k(k))
|
|
65
|
+
except ValueError as exc:
|
|
66
|
+
raise ToolError(str(exc)) from exc
|
|
67
|
+
return {"country": code, "lat": lat, "lon": lon, "results": list(hits)}
|
|
68
|
+
|
|
69
|
+
def batch(self, queries: List[str], country: str, k: int = 1) -> Dict[str, Any]:
|
|
70
|
+
"""Many addresses in one call (up to MAX_BATCH); results keep the input order."""
|
|
71
|
+
if not isinstance(queries, list) or not queries:
|
|
72
|
+
raise ToolError("queries must be a non-empty list of address strings")
|
|
73
|
+
if len(queries) > MAX_BATCH:
|
|
74
|
+
raise ToolError(
|
|
75
|
+
f"at most {MAX_BATCH} queries per call, got {len(queries)}; split the list, "
|
|
76
|
+
"or use the Python package or the CLI `gridpin batch` for files"
|
|
77
|
+
)
|
|
78
|
+
if not all(isinstance(q, str) for q in queries):
|
|
79
|
+
raise ToolError("every query must be a string")
|
|
80
|
+
code, geo = self.geocoder(country)
|
|
81
|
+
rows = geo.geocode_many(queries, _check_k(k))
|
|
82
|
+
return {
|
|
83
|
+
"country": code,
|
|
84
|
+
"results": [{"query": q, "results": list(r)} for q, r in zip(queries, rows)],
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def countries() -> Dict[str, Any]:
|
|
89
|
+
"""Every country this release covers, with download state and size."""
|
|
90
|
+
return {
|
|
91
|
+
"release": sheets.RELEASE,
|
|
92
|
+
"home": str(sheets.home()),
|
|
93
|
+
"countries": [
|
|
94
|
+
{
|
|
95
|
+
"code": s.code,
|
|
96
|
+
"name": s.name,
|
|
97
|
+
"size_mb": round(s.size / 1e6),
|
|
98
|
+
"downloaded": sheets.is_downloaded(s),
|
|
99
|
+
}
|
|
100
|
+
for s in sheets.SHEETS.values()
|
|
101
|
+
],
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def download_sheet(country: str, opener=None) -> Dict[str, Any]:
|
|
106
|
+
"""Fetch one country sheet (network) and verify it against the signed release."""
|
|
107
|
+
try:
|
|
108
|
+
sheet = sheets.resolve(country)
|
|
109
|
+
already = sheets.is_downloaded(sheet)
|
|
110
|
+
path = sheets.download(country, **({"opener": opener} if opener else {}))
|
|
111
|
+
except sheets.SheetError as exc:
|
|
112
|
+
raise ToolError(str(exc)) from exc
|
|
113
|
+
return {
|
|
114
|
+
"country": sheet.code,
|
|
115
|
+
"path": str(path),
|
|
116
|
+
"size_mb": round(sheet.size / 1e6),
|
|
117
|
+
"already_downloaded": already,
|
|
118
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gridpin-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for offline geocoding with GridPin: addresses to coordinates and back from one local file per country, no API key
|
|
5
|
+
License-Expression: Apache-2.0
|
|
6
|
+
Project-URL: Homepage, https://gridpin.dev
|
|
7
|
+
Project-URL: Documentation, https://gridpin.dev/docs
|
|
8
|
+
Project-URL: Repository, https://github.com/gridpin/gridpin
|
|
9
|
+
Project-URL: Issues, https://github.com/gridpin/gridpin/issues
|
|
10
|
+
Keywords: mcp,geocoding,geocoder,offline,reverse-geocoding,gis,overture,ai-agents
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: GIS
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: gridpin<0.5,>=0.4.0
|
|
18
|
+
Requires-Dist: mcp<3,>=2.3
|
|
19
|
+
Requires-Dist: typing-extensions>=4.13
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# gridpin-mcp — offline geocoding for AI agents
|
|
23
|
+
|
|
24
|
+
<!-- mcp-name: io.github.gridpin/gridpin-mcp -->
|
|
25
|
+
|
|
26
|
+
An [MCP](https://modelcontextprotocol.io) server that lets an agent geocode addresses with
|
|
27
|
+
[GridPin](https://gridpin.dev): one memory-mapped file per country, read on your machine.
|
|
28
|
+
No API key, no rate limit, and no query leaves the machine. The only network step is
|
|
29
|
+
downloading a country sheet once, verified against the signed GridPin release.
|
|
30
|
+
|
|
31
|
+
Countries (release v0.4.0): Brazil, France, Germany, Italy, Netherlands, Serbia, Spain.
|
|
32
|
+
|
|
33
|
+
## Add it to an agent
|
|
34
|
+
|
|
35
|
+
Claude Code:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ claude mcp add gridpin -- uvx gridpin-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Any client that reads an `mcpServers` config (Claude Desktop, Cursor, ...):
|
|
42
|
+
|
|
43
|
+
```json
|
|
44
|
+
{ "mcpServers": { "gridpin": { "command": "uvx", "args": ["gridpin-mcp"] } } }
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Tools
|
|
48
|
+
|
|
49
|
+
| Tool | What it does |
|
|
50
|
+
|---|---|
|
|
51
|
+
| `list_countries` | covered countries, sheet sizes, which are downloaded |
|
|
52
|
+
| `download_sheet` | fetch one country sheet (32-677 MB) once, verify its SHA-256; the only tool that uses the network |
|
|
53
|
+
| `geocode` | address text -> ranked candidates with `lat`, `lon`, `precision`, `confidence` |
|
|
54
|
+
| `reverse` | coordinates -> nearest indexed addresses with `distance_m` |
|
|
55
|
+
| `batch` | up to 1000 addresses of one country per call, in input order |
|
|
56
|
+
|
|
57
|
+
Results are the same as the GridPin CLI, Python and DuckDB return. Treat `street` or
|
|
58
|
+
`city` precision as approximate.
|
|
59
|
+
|
|
60
|
+
## Download a sheet without MCP
|
|
61
|
+
|
|
62
|
+
```console
|
|
63
|
+
$ pip install gridpin-mcp
|
|
64
|
+
$ gridpin-fetch fr # prints the path, e.g. ~/.cache/gridpin/v0.4.0/france.bin
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
import gridpin
|
|
69
|
+
from gridpin_mcp import sheets
|
|
70
|
+
|
|
71
|
+
path = sheets.download("fr") # no-op when already downloaded
|
|
72
|
+
gridpin.Geocoder(str(path)).geocode("1 rue de la Paix, Paris", 1)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Sheets go to `$GRIDPIN_HOME`, else `$XDG_CACHE_HOME/gridpin`, else `~/.cache/gridpin`.
|
|
76
|
+
Sizes and SHA-256 of every sheet are pinned in the package from the signed release
|
|
77
|
+
attestation; a download that does not match is discarded.
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
Apache-2.0. Sheet data: see [attributions](https://github.com/gridpin/gridpin/blob/main/ATTRIBUTIONS.md).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/gridpin_mcp/__init__.py
|
|
5
|
+
src/gridpin_mcp/cli.py
|
|
6
|
+
src/gridpin_mcp/server.py
|
|
7
|
+
src/gridpin_mcp/sheets.py
|
|
8
|
+
src/gridpin_mcp/tools.py
|
|
9
|
+
src/gridpin_mcp.egg-info/PKG-INFO
|
|
10
|
+
src/gridpin_mcp.egg-info/SOURCES.txt
|
|
11
|
+
src/gridpin_mcp.egg-info/dependency_links.txt
|
|
12
|
+
src/gridpin_mcp.egg-info/entry_points.txt
|
|
13
|
+
src/gridpin_mcp.egg-info/requires.txt
|
|
14
|
+
src/gridpin_mcp.egg-info/top_level.txt
|
|
15
|
+
tests/test_server.py
|
|
16
|
+
tests/test_sheets.py
|
|
17
|
+
tests/test_tools.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gridpin_mcp
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""The MCP wiring, exercised through the SDK's in-process client."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
# Not importorskip("mcp"): run from the repository root, the mcp/ folder of this package is an
|
|
9
|
+
# importable namespace package named "mcp", so that check passes without the SDK and the
|
|
10
|
+
# collection fails. A real SDK module is the only proof the SDK is installed.
|
|
11
|
+
pytest.importorskip("mcp.server.mcpserver")
|
|
12
|
+
if not hasattr(pytest.importorskip("gridpin"), "Geocoder"): # same trap for the gridpin/ folder
|
|
13
|
+
pytest.skip("gridpin engine wheel not installed", allow_module_level=True)
|
|
14
|
+
from mcp import Client # noqa: E402
|
|
15
|
+
|
|
16
|
+
from gridpin_mcp import server, tools # noqa: E402
|
|
17
|
+
|
|
18
|
+
CODE = Path(__file__).resolve().parents[2]
|
|
19
|
+
SHEET = Path(os.environ.get("GRIDPIN_TEST_SHEET", CODE / "data" / "mc.bin"))
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run(coro):
|
|
23
|
+
return asyncio.run(coro)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@pytest.fixture
|
|
27
|
+
def app():
|
|
28
|
+
if not SHEET.exists():
|
|
29
|
+
pytest.skip(f"no test sheet at {SHEET} (make mc)")
|
|
30
|
+
return server.build_server(tools.Session(open_sheet=lambda code: SHEET))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_tools_are_listed_with_read_only_hints(app):
|
|
34
|
+
async def go():
|
|
35
|
+
async with Client(app) as client:
|
|
36
|
+
return (await client.list_tools()).tools
|
|
37
|
+
listed = {t.name: t for t in run(go())}
|
|
38
|
+
assert set(listed) == {"list_countries", "download_sheet", "geocode", "reverse", "batch"}
|
|
39
|
+
for name in ("list_countries", "geocode", "reverse", "batch"):
|
|
40
|
+
assert listed[name].annotations.read_only_hint is True, name
|
|
41
|
+
assert listed["download_sheet"].annotations.read_only_hint is False
|
|
42
|
+
assert listed["download_sheet"].annotations.open_world_hint is True
|
|
43
|
+
assert "query" in listed["geocode"].input_schema["properties"]
|
|
44
|
+
# Agents see the result fields in the schema, not an opaque object.
|
|
45
|
+
hit = listed["geocode"].output_schema["$defs"]["Hit"]
|
|
46
|
+
assert {"lat", "lon", "precision", "confidence"} <= set(hit["required"])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_geocode_through_mcp_returns_structured_results(app):
|
|
50
|
+
async def go():
|
|
51
|
+
async with Client(app) as client:
|
|
52
|
+
return await client.call_tool("geocode", {"query": "boulevard des moulins 27 monaco", "country": "fr"})
|
|
53
|
+
result = run(go())
|
|
54
|
+
assert not result.is_error
|
|
55
|
+
assert result.structured_content["results"][0]["lat"] > 43
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_anticipated_error_reaches_the_model_verbatim(app):
|
|
59
|
+
async def go():
|
|
60
|
+
async with Client(app) as client:
|
|
61
|
+
return await client.call_tool("geocode", {"query": "x", "country": "Poland"})
|
|
62
|
+
result = run(go())
|
|
63
|
+
assert result.is_error
|
|
64
|
+
text = result.content[0].text
|
|
65
|
+
# The SDK prefixes the tool name; a crash would carry nothing after it.
|
|
66
|
+
assert text.startswith("Error executing tool geocode: unknown country 'Poland'; available: br (Brazil)")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_instructions_name_every_country_and_the_offline_promise():
|
|
70
|
+
text = server.INSTRUCTIONS
|
|
71
|
+
assert "no query leaves the machine" in text
|
|
72
|
+
for name in ("Brazil", "France", "Germany", "Italy", "Netherlands", "Serbia", "Spain"):
|
|
73
|
+
assert name in text
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Sheet resolution, cache location and verified downloads, with a fake network."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import io
|
|
4
|
+
from collections import namedtuple
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from gridpin_mcp import sheets
|
|
9
|
+
|
|
10
|
+
PAYLOAD = b"gridpin-sheet-bytes" * 1000
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FakeResponse(io.BytesIO):
|
|
14
|
+
def __enter__(self):
|
|
15
|
+
return self
|
|
16
|
+
|
|
17
|
+
def __exit__(self, *exc):
|
|
18
|
+
self.close()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def home(tmp_path, monkeypatch):
|
|
23
|
+
monkeypatch.setenv("GRIDPIN_HOME", str(tmp_path))
|
|
24
|
+
return tmp_path
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def tiny(monkeypatch):
|
|
29
|
+
"""Replace the French sheet pin with a small payload of known size and hash."""
|
|
30
|
+
sheet = sheets.Sheet("fr", "France", "france.bin", len(PAYLOAD), hashlib.sha256(PAYLOAD).hexdigest())
|
|
31
|
+
monkeypatch.setitem(sheets.SHEETS, "fr", sheet)
|
|
32
|
+
return sheet
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def opener_for(payload, calls=None):
|
|
36
|
+
def opener(url):
|
|
37
|
+
if calls is not None:
|
|
38
|
+
calls.append(url)
|
|
39
|
+
return FakeResponse(payload)
|
|
40
|
+
return opener
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def test_resolve_accepts_code_and_english_name_in_any_case():
|
|
44
|
+
assert sheets.resolve("fr").file == "france.bin"
|
|
45
|
+
assert sheets.resolve(" FR ").code == "fr"
|
|
46
|
+
assert sheets.resolve("Netherlands").code == "nl"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_resolve_unknown_country_lists_what_is_available():
|
|
50
|
+
with pytest.raises(sheets.SheetError) as err:
|
|
51
|
+
sheets.resolve("pl")
|
|
52
|
+
assert "unknown country 'pl'" in str(err.value)
|
|
53
|
+
assert all(code in str(err.value) for code in sheets.SHEETS)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_every_pin_is_a_full_sha256_and_url_points_to_the_release():
|
|
57
|
+
assert len(sheets.SHEETS) == 7
|
|
58
|
+
for s in sheets.SHEETS.values():
|
|
59
|
+
assert len(s.sha256) == 64 and int(s.sha256, 16) >= 0
|
|
60
|
+
assert s.url == f"https://dl.gridpin.dev/{sheets.RELEASE}/{s.file}"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_home_prefers_gridpin_home_then_xdg(tmp_path, monkeypatch):
|
|
64
|
+
monkeypatch.delenv("GRIDPIN_HOME", raising=False)
|
|
65
|
+
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
|
|
66
|
+
assert sheets.home() == tmp_path / "xdg" / "gridpin"
|
|
67
|
+
monkeypatch.setenv("GRIDPIN_HOME", str(tmp_path / "own"))
|
|
68
|
+
assert sheets.home() == tmp_path / "own"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_missing_sheet_error_names_the_fetch_command(home):
|
|
72
|
+
with pytest.raises(sheets.SheetError) as err:
|
|
73
|
+
sheets.sheet_path("de")
|
|
74
|
+
assert "`gridpin-fetch de`" in str(err.value) and "download_sheet" in str(err.value)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_download_verifies_and_moves_into_place(home, tiny):
|
|
78
|
+
seen = []
|
|
79
|
+
path = sheets.download("France", opener=opener_for(PAYLOAD), progress=lambda d, t: seen.append((d, t)))
|
|
80
|
+
assert path == home / sheets.RELEASE / "france.bin"
|
|
81
|
+
assert path.read_bytes() == PAYLOAD
|
|
82
|
+
assert sheets.sheet_path("fr") == path
|
|
83
|
+
assert seen[-1] == (len(PAYLOAD), len(PAYLOAD))
|
|
84
|
+
assert [p.name for p in path.parent.iterdir()] == ["france.bin"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_already_downloaded_sheet_is_not_fetched_again(home, tiny):
|
|
88
|
+
sheets.download("fr", opener=opener_for(PAYLOAD))
|
|
89
|
+
calls = []
|
|
90
|
+
sheets.download("fr", opener=opener_for(PAYLOAD, calls))
|
|
91
|
+
assert calls == []
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("payload, message", [
|
|
95
|
+
(PAYLOAD[:-1], "expected"), # truncated
|
|
96
|
+
(PAYLOAD + b"x", "more than"), # oversized stream is cut off
|
|
97
|
+
(PAYLOAD[:-1] + b"X", "SHA-256 does not match"), # same size, one byte changed
|
|
98
|
+
])
|
|
99
|
+
def test_bad_download_is_discarded(home, tiny, payload, message):
|
|
100
|
+
with pytest.raises(sheets.SheetError) as err:
|
|
101
|
+
sheets.download("fr", opener=opener_for(payload))
|
|
102
|
+
assert message in str(err.value)
|
|
103
|
+
folder = home / sheets.RELEASE
|
|
104
|
+
assert list(folder.iterdir()) == []
|
|
105
|
+
assert not sheets.is_downloaded(tiny)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_not_enough_disk_space_stops_before_any_request(home, tiny, monkeypatch):
|
|
109
|
+
Usage = namedtuple("Usage", "total used free")
|
|
110
|
+
monkeypatch.setattr(sheets.shutil, "disk_usage", lambda p: Usage(0, 0, tiny.size + sheets.DISK_MARGIN - 1))
|
|
111
|
+
calls = []
|
|
112
|
+
with pytest.raises(sheets.SheetError) as err:
|
|
113
|
+
sheets.download("fr", opener=opener_for(PAYLOAD, calls))
|
|
114
|
+
assert "not enough disk space" in str(err.value) and "GRIDPIN_HOME" in str(err.value)
|
|
115
|
+
assert calls == []
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_a_wrong_size_file_in_place_is_not_treated_as_downloaded(home, tiny):
|
|
119
|
+
target = home / sheets.RELEASE / "france.bin"
|
|
120
|
+
target.parent.mkdir(parents=True)
|
|
121
|
+
target.write_bytes(PAYLOAD[:10])
|
|
122
|
+
assert not sheets.is_downloaded(tiny)
|
|
123
|
+
with pytest.raises(sheets.SheetError):
|
|
124
|
+
sheets.sheet_path("fr")
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""Agent-facing tools over the real engine and the Monaco smoke sheet (make mc)."""
|
|
2
|
+
import os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
# From the repository root the gridpin/ crate folder is a namespace package named "gridpin";
|
|
8
|
+
# only the engine wheel defines Geocoder.
|
|
9
|
+
if not hasattr(pytest.importorskip("gridpin"), "Geocoder"):
|
|
10
|
+
pytest.skip("gridpin engine wheel not installed", allow_module_level=True)
|
|
11
|
+
from gridpin_mcp import sheets, tools # noqa: E402
|
|
12
|
+
|
|
13
|
+
CODE = Path(__file__).resolve().parents[2]
|
|
14
|
+
SHEET = Path(os.environ.get("GRIDPIN_TEST_SHEET", CODE / "data" / "mc.bin"))
|
|
15
|
+
ADDRESS = "boulevard des moulins 27 monaco"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def session():
|
|
20
|
+
if not SHEET.exists():
|
|
21
|
+
pytest.skip(f"no test sheet at {SHEET} (build the Monaco smoke sheet: make mc)")
|
|
22
|
+
opened = []
|
|
23
|
+
|
|
24
|
+
def open_sheet(code):
|
|
25
|
+
opened.append(code)
|
|
26
|
+
return SHEET
|
|
27
|
+
|
|
28
|
+
s = tools.Session(open_sheet=open_sheet)
|
|
29
|
+
s.opened = opened
|
|
30
|
+
return s
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_geocode_returns_engine_results_with_coordinates(session):
|
|
34
|
+
out = session.geocode(ADDRESS, "France", k=3)
|
|
35
|
+
assert out["country"] == "fr" and out["query"] == ADDRESS
|
|
36
|
+
top = out["results"][0]
|
|
37
|
+
assert isinstance(top["lat"], float) and isinstance(top["lon"], float)
|
|
38
|
+
assert {"precision", "confidence", "street", "commune"} <= set(top)
|
|
39
|
+
assert len(out["results"]) <= 3
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_sheet_is_opened_once_per_country(session):
|
|
43
|
+
session.geocode(ADDRESS, "fr")
|
|
44
|
+
session.geocode(ADDRESS, "FR")
|
|
45
|
+
session.reverse(43.74, 7.43, "France")
|
|
46
|
+
assert session.opened == ["fr"]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_reverse_carries_distance(session):
|
|
50
|
+
out = session.reverse(43.7397, 7.4265, "fr", k=2)
|
|
51
|
+
assert out["results"] and all("distance_m" in r for r in out["results"])
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_reverse_rejects_impossible_coordinates_with_a_readable_error(session):
|
|
55
|
+
with pytest.raises(tools.ToolError):
|
|
56
|
+
session.reverse(123.0, 7.0, "fr")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_batch_keeps_input_order_and_pairs_queries(session):
|
|
60
|
+
# Asymmetric on purpose: a reversed or shifted pairing must change the answer.
|
|
61
|
+
queries = [ADDRESS, "xyzzy plugh nonexistent street 999", "xyzzy plugh nonexistent street 998"]
|
|
62
|
+
out = session.batch(queries, "fr")
|
|
63
|
+
assert [row["query"] for row in out["results"]] == queries
|
|
64
|
+
assert out["results"][0]["results"][0]["housenumber"] == "27"
|
|
65
|
+
assert out["results"][1]["results"] == [] and out["results"][2]["results"] == []
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.mark.parametrize("call, message", [
|
|
69
|
+
(lambda s: s.geocode(" ", "fr"), "non-empty"),
|
|
70
|
+
(lambda s: s.geocode(ADDRESS, "fr", k=0), "k must be"),
|
|
71
|
+
(lambda s: s.geocode(ADDRESS, "fr", k=11), "k must be"),
|
|
72
|
+
(lambda s: s.batch([], "fr"), "non-empty list"),
|
|
73
|
+
(lambda s: s.batch([ADDRESS, 5], "fr"), "must be a string"),
|
|
74
|
+
(lambda s: s.batch([ADDRESS] * (tools.MAX_BATCH + 1), "fr"), "at most 1000"),
|
|
75
|
+
(lambda s: s.geocode(ADDRESS, "Poland"), "unknown country"),
|
|
76
|
+
])
|
|
77
|
+
def test_bad_input_is_a_tool_error_that_says_what_to_do(session, call, message):
|
|
78
|
+
with pytest.raises(tools.ToolError) as err:
|
|
79
|
+
call(session)
|
|
80
|
+
assert message in str(err.value)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_batch_accepts_exactly_the_limit(session):
|
|
84
|
+
out = session.batch([ADDRESS] * tools.MAX_BATCH, "fr")
|
|
85
|
+
assert len(out["results"]) == tools.MAX_BATCH
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_missing_sheet_becomes_tool_error_naming_the_download(tmp_path, monkeypatch):
|
|
89
|
+
monkeypatch.setenv("GRIDPIN_HOME", str(tmp_path))
|
|
90
|
+
with pytest.raises(tools.ToolError) as err:
|
|
91
|
+
tools.Session().geocode(ADDRESS, "it")
|
|
92
|
+
assert "gridpin-fetch it" in str(err.value)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def test_countries_reports_download_state(tmp_path, monkeypatch):
|
|
96
|
+
monkeypatch.setenv("GRIDPIN_HOME", str(tmp_path))
|
|
97
|
+
out = tools.countries()
|
|
98
|
+
assert out["release"] == sheets.RELEASE
|
|
99
|
+
assert {c["code"] for c in out["countries"]} == set(sheets.SHEETS)
|
|
100
|
+
assert not any(c["downloaded"] for c in out["countries"])
|