env-able 2.0.0__tar.gz → 2.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.
- {env_able-2.0.0 → env_able-2.1.0}/.gitignore +1 -0
- {env_able-2.0.0 → env_able-2.1.0}/CHANGELOG.md +35 -0
- {env_able-2.0.0 → env_able-2.1.0}/PKG-INFO +44 -1
- {env_able-2.0.0 → env_able-2.1.0}/README.md +43 -0
- {env_able-2.0.0 → env_able-2.1.0}/pyproject.toml +1 -1
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/__init__.py +3 -2
- env_able-2.1.0/src/env_able/atlas/calc.py +308 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-guide.md +141 -19
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/env-able-library.md +14 -3
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/index.html +24879 -23605
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/server.py +13975 -13051
- env_able-2.1.0/src/env_able/remote.py +326 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/vector.py +137 -0
- env_able-2.0.0/src/env_able/remote.py +0 -203
- {env_able-2.0.0 → env_able-2.1.0}/LICENSE +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_gdal_env.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_gdal_utils.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_pipeline.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_result.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/aggregate.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/__init__.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/aix.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/brandmark.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/claude_bridge.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/client.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/compliance.html +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/databricks.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/debug.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/desktop.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-layout-view.md +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/cartographic-judgment.md +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/edits.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/eirstyle.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/env_layers.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/landing.html +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/naming.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/palette.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/project.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/session.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/splash.html +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Landscape.pagx +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Portrait.pagx +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Landscape.pagx +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Portrait.pagx +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/HTML-TO-IMAGE-LICENSE.txt +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/MAPLIBRE-LICENSE.txt +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/PROTOMAPS-LICENSE.txt +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/ROBOTO-LICENSE.txt +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/html-to-image.js +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.css +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.js +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/pmtiles.js +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/protomaps-basemaps.js +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-italic.woff2 +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-normal.woff2 +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-500-normal.woff2 +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-700-normal.woff2 +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/classify.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/layout.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/morph.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/pull.py +0 -0
- {env_able-2.0.0 → env_able-2.1.0}/src/env_able/stream.py +0 -0
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [2.0.1] - 2026-10-15
|
|
4
|
+
### Added
|
|
5
|
+
- **Calculate Field** in the attribute table: ArcGIS Pro's `!FIELD!` syntax, the selected
|
|
6
|
+
records only when there is a selection, Preview, and one Undo for the whole column. Parsed with
|
|
7
|
+
`ast` and interpreted, never `eval`'d: no import, no attribute walk, no oversized strings.
|
|
8
|
+
- **The attribute table has Pro's toolbar**: All | Selected tabs with counts, Select All, Switch,
|
|
9
|
+
Clear, Zoom To, Add Field and Calculate.
|
|
10
|
+
- **Select is an arrow with a ▾ menu** of Click, Rectangle and Lasso, each with its icon, as in Pro.
|
|
11
|
+
- **Click mode is Pro's Select**: a click picks the topmost feature, a drag picks a box, and
|
|
12
|
+
Shift adds and Ctrl removes either way. Middle-drag pans while Select is on.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- **Shift-drag to add to a selection zoomed the map instead**: Shift-drag is MapLibre's box zoom.
|
|
16
|
+
Select turns it off while it is on. A tiny Ctrl-drag no longer clears the selection.
|
|
17
|
+
- **Opening an attribute table started editing**, showing Undo, Save As and "Editing" to somebody
|
|
18
|
+
who had come to read it. Edit, Add Field and Calculate arm it now.
|
|
19
|
+
- **A `.shp` dragged on its own lost its `.prj` and `.dbf`**: the CRS was guessed from the
|
|
20
|
+
coordinate range and every attribute dropped. Atlas now reads the file where it lives, sidecars
|
|
21
|
+
and all, when name, size, modified time and a hash of its first and last 256 KB match.
|
|
22
|
+
- **From URL on an ArcGIS group layer loaded nothing**: the group was queried directly and the
|
|
23
|
+
service answered "Invalid or missing input parameters". A group now loads as its sublayers, a
|
|
24
|
+
whole `…/MapServer` as every feature layer in it, and a `…/query` address as its layer.
|
|
25
|
+
- **An upload froze the whole application while it loaded**: the read ran on the server's event
|
|
26
|
+
loop, so the poll, the tiles and the Claude pane all waited. It runs on a worker thread now.
|
|
27
|
+
- **From URL held the layer lock for the whole download**, so every other map change waited on a
|
|
28
|
+
remote server. It is held for the add only.
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- **Add Layer no longer uploads a file Atlas can find on disk.** A 34,596-feature well layer
|
|
32
|
+
(183 MB with its `.dbf`) went from 11.2 s to 3.5 s, and is referenced by a save rather than copied.
|
|
33
|
+
- **GeoJSON conversion is 40% faster** (3.3 s to 2.0 s on that layer): attributes column-wise, and
|
|
34
|
+
points and lines from one vectorised coordinate read. Byte-identical output.
|
|
35
|
+
- **ArcGIS services are fetched by object id, six pages at a time, three layers at once**, with a
|
|
36
|
+
toast that counts features as they arrive. A ten-layer Alberta service: 72 s to 31 s.
|
|
37
|
+
|
|
3
38
|
## [2.0.0] - 2026-10-07
|
|
4
39
|
Atlas becomes two products on one engine. **Atlas Pro** (`atlas-pro`) is the desktop GIS and draws
|
|
5
40
|
only with MapLibre. **atlas** is the skill: a folder in, a finished map out, in any assistant or an
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: env-able
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: AI-ready GIS toolkit for energy and subsurface workflows.
|
|
5
5
|
License: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -235,6 +235,14 @@ Lines and polygons with fewer vertices, no edge moved more than `tolerance`. Mea
|
|
|
235
235
|
result = env.Simplify("counties.shp", 200, "meters", output="counties_light.gpkg")
|
|
236
236
|
```
|
|
237
237
|
|
|
238
|
+
### `env.SummaryStatistics(input_table, statistics_fields, case_field=None, output=None, separator=", ")`
|
|
239
|
+
ArcGIS Pro's Summary Statistics: one row per unique set of `case_field` values, with each statistic asked for, as a **table** (no geometry). The input is a path, a DataFrame or a GeoDataFrame. The statistics are Dissolve's plus `MODE`; a text field takes only `MIN`, `MAX`, `COUNT`, `FIRST`, `LAST`, `UNIQUE`, `CONCATENATE` and `MODE`. Columns are named `SUM_EUR`, and blanks are left out of every statistic.
|
|
240
|
+
|
|
241
|
+
```python
|
|
242
|
+
result = env.SummaryStatistics("wells.gpkg", [["EUR", "SUM"], ["API", "COUNT"]], case_field="OPERATOR")
|
|
243
|
+
result.gdf # a pandas DataFrame, one row per operator
|
|
244
|
+
```
|
|
245
|
+
|
|
238
246
|
---
|
|
239
247
|
|
|
240
248
|
### `env.load(source)` — fluent spatial pipeline
|
|
@@ -404,6 +412,41 @@ print(f"{rows:,} rows written")
|
|
|
404
412
|
---
|
|
405
413
|
|
|
406
414
|
## Changelog
|
|
415
|
+
### v2.0.1 — 2026-10-15
|
|
416
|
+
**Added**
|
|
417
|
+
- **Calculate Field** in the attribute table: ArcGIS Pro's `!FIELD!` syntax, the selected
|
|
418
|
+
records only when there is a selection, Preview, and one Undo for the whole column. Parsed with
|
|
419
|
+
`ast` and interpreted, never `eval`'d: no import, no attribute walk, no oversized strings.
|
|
420
|
+
- **The attribute table has Pro's toolbar**: All | Selected tabs with counts, Select All, Switch,
|
|
421
|
+
Clear, Zoom To, Add Field and Calculate.
|
|
422
|
+
- **Select is an arrow with a ▾ menu** of Click, Rectangle and Lasso, each with its icon, as in Pro.
|
|
423
|
+
- **Click mode is Pro's Select**: a click picks the topmost feature, a drag picks a box, and
|
|
424
|
+
Shift adds and Ctrl removes either way. Middle-drag pans while Select is on.
|
|
425
|
+
|
|
426
|
+
**Fixed**
|
|
427
|
+
- **Shift-drag to add to a selection zoomed the map instead**: Shift-drag is MapLibre's box zoom.
|
|
428
|
+
Select turns it off while it is on. A tiny Ctrl-drag no longer clears the selection.
|
|
429
|
+
- **Opening an attribute table started editing**, showing Undo, Save As and "Editing" to somebody
|
|
430
|
+
who had come to read it. Edit, Add Field and Calculate arm it now.
|
|
431
|
+
- **A `.shp` dragged on its own lost its `.prj` and `.dbf`**: the CRS was guessed from the
|
|
432
|
+
coordinate range and every attribute dropped. Atlas now reads the file where it lives, sidecars
|
|
433
|
+
and all, when name, size, modified time and a hash of its first and last 256 KB match.
|
|
434
|
+
- **From URL on an ArcGIS group layer loaded nothing**: the group was queried directly and the
|
|
435
|
+
service answered "Invalid or missing input parameters". A group now loads as its sublayers, a
|
|
436
|
+
whole `…/MapServer` as every feature layer in it, and a `…/query` address as its layer.
|
|
437
|
+
- **An upload froze the whole application while it loaded**: the read ran on the server's event
|
|
438
|
+
loop, so the poll, the tiles and the Claude pane all waited. It runs on a worker thread now.
|
|
439
|
+
- **From URL held the layer lock for the whole download**, so every other map change waited on a
|
|
440
|
+
remote server. It is held for the add only.
|
|
441
|
+
|
|
442
|
+
**Changed**
|
|
443
|
+
- **Add Layer no longer uploads a file Atlas can find on disk.** A 34,596-feature well layer
|
|
444
|
+
(183 MB with its `.dbf`) went from 11.2 s to 3.5 s, and is referenced by a save rather than copied.
|
|
445
|
+
- **GeoJSON conversion is 40% faster** (3.3 s to 2.0 s on that layer): attributes column-wise, and
|
|
446
|
+
points and lines from one vectorised coordinate read. Byte-identical output.
|
|
447
|
+
- **ArcGIS services are fetched by object id, six pages at a time, three layers at once**, with a
|
|
448
|
+
toast that counts features as they arrive. A ten-layer Alberta service: 72 s to 31 s.
|
|
449
|
+
|
|
407
450
|
### v2.0.0 — 2026-10-07
|
|
408
451
|
Atlas becomes two products on one engine. **Atlas Pro** (`atlas-pro`) is the desktop GIS and draws
|
|
409
452
|
only with MapLibre. **atlas** is the skill: a folder in, a finished map out, in any assistant or an
|
|
@@ -209,6 +209,14 @@ Lines and polygons with fewer vertices, no edge moved more than `tolerance`. Mea
|
|
|
209
209
|
result = env.Simplify("counties.shp", 200, "meters", output="counties_light.gpkg")
|
|
210
210
|
```
|
|
211
211
|
|
|
212
|
+
### `env.SummaryStatistics(input_table, statistics_fields, case_field=None, output=None, separator=", ")`
|
|
213
|
+
ArcGIS Pro's Summary Statistics: one row per unique set of `case_field` values, with each statistic asked for, as a **table** (no geometry). The input is a path, a DataFrame or a GeoDataFrame. The statistics are Dissolve's plus `MODE`; a text field takes only `MIN`, `MAX`, `COUNT`, `FIRST`, `LAST`, `UNIQUE`, `CONCATENATE` and `MODE`. Columns are named `SUM_EUR`, and blanks are left out of every statistic.
|
|
214
|
+
|
|
215
|
+
```python
|
|
216
|
+
result = env.SummaryStatistics("wells.gpkg", [["EUR", "SUM"], ["API", "COUNT"]], case_field="OPERATOR")
|
|
217
|
+
result.gdf # a pandas DataFrame, one row per operator
|
|
218
|
+
```
|
|
219
|
+
|
|
212
220
|
---
|
|
213
221
|
|
|
214
222
|
### `env.load(source)` — fluent spatial pipeline
|
|
@@ -378,6 +386,41 @@ print(f"{rows:,} rows written")
|
|
|
378
386
|
---
|
|
379
387
|
|
|
380
388
|
## Changelog
|
|
389
|
+
### v2.0.1 — 2026-10-15
|
|
390
|
+
**Added**
|
|
391
|
+
- **Calculate Field** in the attribute table: ArcGIS Pro's `!FIELD!` syntax, the selected
|
|
392
|
+
records only when there is a selection, Preview, and one Undo for the whole column. Parsed with
|
|
393
|
+
`ast` and interpreted, never `eval`'d: no import, no attribute walk, no oversized strings.
|
|
394
|
+
- **The attribute table has Pro's toolbar**: All | Selected tabs with counts, Select All, Switch,
|
|
395
|
+
Clear, Zoom To, Add Field and Calculate.
|
|
396
|
+
- **Select is an arrow with a ▾ menu** of Click, Rectangle and Lasso, each with its icon, as in Pro.
|
|
397
|
+
- **Click mode is Pro's Select**: a click picks the topmost feature, a drag picks a box, and
|
|
398
|
+
Shift adds and Ctrl removes either way. Middle-drag pans while Select is on.
|
|
399
|
+
|
|
400
|
+
**Fixed**
|
|
401
|
+
- **Shift-drag to add to a selection zoomed the map instead**: Shift-drag is MapLibre's box zoom.
|
|
402
|
+
Select turns it off while it is on. A tiny Ctrl-drag no longer clears the selection.
|
|
403
|
+
- **Opening an attribute table started editing**, showing Undo, Save As and "Editing" to somebody
|
|
404
|
+
who had come to read it. Edit, Add Field and Calculate arm it now.
|
|
405
|
+
- **A `.shp` dragged on its own lost its `.prj` and `.dbf`**: the CRS was guessed from the
|
|
406
|
+
coordinate range and every attribute dropped. Atlas now reads the file where it lives, sidecars
|
|
407
|
+
and all, when name, size, modified time and a hash of its first and last 256 KB match.
|
|
408
|
+
- **From URL on an ArcGIS group layer loaded nothing**: the group was queried directly and the
|
|
409
|
+
service answered "Invalid or missing input parameters". A group now loads as its sublayers, a
|
|
410
|
+
whole `…/MapServer` as every feature layer in it, and a `…/query` address as its layer.
|
|
411
|
+
- **An upload froze the whole application while it loaded**: the read ran on the server's event
|
|
412
|
+
loop, so the poll, the tiles and the Claude pane all waited. It runs on a worker thread now.
|
|
413
|
+
- **From URL held the layer lock for the whole download**, so every other map change waited on a
|
|
414
|
+
remote server. It is held for the add only.
|
|
415
|
+
|
|
416
|
+
**Changed**
|
|
417
|
+
- **Add Layer no longer uploads a file Atlas can find on disk.** A 34,596-feature well layer
|
|
418
|
+
(183 MB with its `.dbf`) went from 11.2 s to 3.5 s, and is referenced by a save rather than copied.
|
|
419
|
+
- **GeoJSON conversion is 40% faster** (3.3 s to 2.0 s on that layer): attributes column-wise, and
|
|
420
|
+
points and lines from one vectorised coordinate read. Byte-identical output.
|
|
421
|
+
- **ArcGIS services are fetched by object id, six pages at a time, three layers at once**, with a
|
|
422
|
+
toast that counts features as they arrive. A ten-layer Alberta service: 72 s to 31 s.
|
|
423
|
+
|
|
381
424
|
### v2.0.0 — 2026-10-07
|
|
382
425
|
Atlas becomes two products on one engine. **Atlas Pro** (`atlas-pro`) is the desktop GIS and draws
|
|
383
426
|
only with MapLibre. **atlas** is the skill: a folder in, a finished map out, in any assistant or an
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
__version__ = "2.
|
|
1
|
+
__version__ = "2.1.0"
|
|
2
2
|
|
|
3
3
|
# Resolve GDAL_DATA / PROJ_LIB before anything imports osgeo. IDEs and bare
|
|
4
4
|
# `python script.py` runs skip conda's activation scripts, which is where those
|
|
@@ -9,8 +9,9 @@ _gdal_env.configure()
|
|
|
9
9
|
|
|
10
10
|
from .vector import (
|
|
11
11
|
Clip, Buffer, Intersect, Union, Dissolve, Near, SpatialJoin, Erase, Simplify,
|
|
12
|
+
SummaryStatistics,
|
|
12
13
|
clip_async, buffer_async, intersect_async, union_async, dissolve_async,
|
|
13
|
-
near_async, spatial_join_async, erase_async, simplify_async,
|
|
14
|
+
near_async, spatial_join_async, erase_async, simplify_async, summary_statistics_async,
|
|
14
15
|
SJ_MATCH,
|
|
15
16
|
load,
|
|
16
17
|
)
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Calculate Field: one expression, evaluated per row, with nothing else reachable.
|
|
3
|
+
|
|
4
|
+
calc.compile_expr("!EUR! * 1000", fields) -> a callable taking a row dict
|
|
5
|
+
calc.compile_expr("!NAME!.upper()", fields)
|
|
6
|
+
|
|
7
|
+
ArcGIS Pro's syntax, so an expression copied out of Pro works here: a field is written
|
|
8
|
+
`!FIELD!`. ArcMap's `[FIELD]` works too, because people still type it, and so does a bare name
|
|
9
|
+
when it is a field (`EUR * 1000`), because that is what people type first.
|
|
10
|
+
|
|
11
|
+
A null propagates: `!EUR! * 1000` on a row with no EUR gives no value rather than failing the
|
|
12
|
+
whole calculation on that row, and a comparison against a null is false. That is what a database
|
|
13
|
+
does, and it is what someone calculating a 30,000-row column with a few blanks in it expects.
|
|
14
|
+
|
|
15
|
+
It is Python's grammar, NOT Python. The expression is parsed with `ast` and walked by an
|
|
16
|
+
interpreter that knows a short list of node types; anything else -- attribute access other than
|
|
17
|
+
the string methods below, comprehensions, lambdas, imports, dunder names, calls to anything not
|
|
18
|
+
in FUNCS -- is refused at compile time with the reason. `eval` is never called. That matters
|
|
19
|
+
because the Claude pane can reach this through the same endpoint, and an expression is text a
|
|
20
|
+
model may have been handed by a document it was asked to read.
|
|
21
|
+
"""
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import ast
|
|
25
|
+
import keyword
|
|
26
|
+
import math
|
|
27
|
+
import operator
|
|
28
|
+
import re
|
|
29
|
+
|
|
30
|
+
def _iif(test, a, b): # never called: ev() evaluates only the branch taken
|
|
31
|
+
return a if test else b
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
FUNCS = {
|
|
35
|
+
"abs": abs, "round": round, "min": min, "max": max, "len": len,
|
|
36
|
+
"str": str, "int": int, "float": float, "bool": bool,
|
|
37
|
+
"sqrt": math.sqrt, "floor": math.floor, "ceil": math.ceil, "log": math.log,
|
|
38
|
+
"log10": math.log10, "exp": math.exp,
|
|
39
|
+
# The function forms people know from ArcMap and from spreadsheets. iif is lazy, so
|
|
40
|
+
# iif(!N! != 0, 1 / !N!, 0) does not divide by zero on the rows it is guarding.
|
|
41
|
+
"iif": _iif, "upper": str.upper, "lower": str.lower, "trim": str.strip,
|
|
42
|
+
}
|
|
43
|
+
#: Functions that pass a null through rather than tripping over it. bool and iif decide what a
|
|
44
|
+
#: null means themselves, so they are not in it.
|
|
45
|
+
_NULL_SAFE = set(FUNCS) - {"bool", "iif"}
|
|
46
|
+
|
|
47
|
+
#: One line each, for the expression box's suggestions. Every function and method is listed;
|
|
48
|
+
#: verify.py checks the two agree, so a function cannot be added without its help.
|
|
49
|
+
HELP = {
|
|
50
|
+
"abs": ("abs(x)", "the value without its sign"),
|
|
51
|
+
"round": ("round(x, n)", "x to n decimal places"),
|
|
52
|
+
"min": ("min(a, b)", "the smaller value"),
|
|
53
|
+
"max": ("max(a, b)", "the larger value"),
|
|
54
|
+
"len": ("len(text)", "how many characters"),
|
|
55
|
+
"str": ("str(x)", "x as text"),
|
|
56
|
+
"int": ("int(x)", "x as a whole number"),
|
|
57
|
+
"float": ("float(x)", "x as a number"),
|
|
58
|
+
"bool": ("bool(x)", "true or false"),
|
|
59
|
+
"sqrt": ("sqrt(x)", "square root"),
|
|
60
|
+
"floor": ("floor(x)", "round down"),
|
|
61
|
+
"ceil": ("ceil(x)", "round up"),
|
|
62
|
+
"log": ("log(x)", "natural logarithm"),
|
|
63
|
+
"log10": ("log10(x)", "base-10 logarithm"),
|
|
64
|
+
"exp": ("exp(x)", "e to the power x"),
|
|
65
|
+
"iif": ("iif(test, a, b)", "a when test is true, otherwise b"),
|
|
66
|
+
"upper": ("upper(text)", "capitals"),
|
|
67
|
+
"lower": ("lower(text)", "lower case"),
|
|
68
|
+
"trim": ("trim(text)", "without leading and trailing spaces"),
|
|
69
|
+
}
|
|
70
|
+
#: Methods a string may be asked for. Each returns a string, a bool or an int, so a chain of
|
|
71
|
+
#: them cannot reach anything that is not data.
|
|
72
|
+
STR_METHODS = {"upper", "lower", "title", "capitalize", "strip", "lstrip", "rstrip", "replace",
|
|
73
|
+
"zfill", "startswith", "endswith", "find", "count", "center", "ljust", "rjust"}
|
|
74
|
+
NAMES = {"None": None, "True": True, "False": False, "pi": math.pi}
|
|
75
|
+
|
|
76
|
+
_BIN = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
|
77
|
+
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv, ast.Mod: operator.mod,
|
|
78
|
+
ast.Pow: operator.pow}
|
|
79
|
+
_UN = {ast.USub: operator.neg, ast.UAdd: operator.pos, ast.Not: operator.not_}
|
|
80
|
+
_CMP = {ast.Eq: operator.eq, ast.NotEq: operator.ne, ast.Lt: operator.lt, ast.LtE: operator.le,
|
|
81
|
+
ast.Gt: operator.gt, ast.GtE: operator.ge, ast.In: lambda a, b: a in b,
|
|
82
|
+
ast.NotIn: lambda a, b: a not in b, ast.Is: operator.is_, ast.IsNot: operator.is_not}
|
|
83
|
+
|
|
84
|
+
_MAX_POW = 1000
|
|
85
|
+
_MAX_LEN = 10_000
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class CalcError(ValueError):
|
|
89
|
+
pass
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _field_token(i: int) -> str:
|
|
93
|
+
return "__f%d" % i
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def compile_expr(expr: str, fields):
|
|
97
|
+
"""
|
|
98
|
+
A function row -> value for `expr`, or CalcError naming what is not allowed.
|
|
99
|
+
|
|
100
|
+
Fields are resolved now, not per row, so a misspelt `!EURR!` fails before anything runs.
|
|
101
|
+
"""
|
|
102
|
+
src = (expr or "").strip()
|
|
103
|
+
if not src:
|
|
104
|
+
raise CalcError("The expression is empty.")
|
|
105
|
+
if len(src) > 2000:
|
|
106
|
+
raise CalcError("The expression is too long (2,000 characters at most).")
|
|
107
|
+
fields = list(fields)
|
|
108
|
+
lookup = {f.lower(): f for f in fields}
|
|
109
|
+
slots = {}
|
|
110
|
+
|
|
111
|
+
def sub(m):
|
|
112
|
+
name = m.group(1).strip()
|
|
113
|
+
real = name if name in fields else lookup.get(name.lower())
|
|
114
|
+
if real is None:
|
|
115
|
+
raise CalcError("!%s! is not a field in this layer." % name)
|
|
116
|
+
if real not in slots:
|
|
117
|
+
slots[real] = _field_token(len(slots))
|
|
118
|
+
return slots[real]
|
|
119
|
+
|
|
120
|
+
src = re.sub(r"!([^!]+)!", sub, src)
|
|
121
|
+
|
|
122
|
+
def sub_bracket(m):
|
|
123
|
+
# [NAME] is a field only when NAME is one, and only where a value could start: after a
|
|
124
|
+
# name, a closing bracket or a quote it is a subscript (!NAME![0], !NAME![:3]), which
|
|
125
|
+
# is Python's and stays Python's.
|
|
126
|
+
before = src[:m.start()].rstrip()
|
|
127
|
+
word = re.search(r"[A-Za-z_]\w*$", before)
|
|
128
|
+
# ...except after a keyword: `not [ID] == 3` is a value, though `not` ends in a letter.
|
|
129
|
+
if before and (before[-1].isalnum() or before[-1] in "_)]'\"") and not (
|
|
130
|
+
word and keyword.iskeyword(word.group(0))):
|
|
131
|
+
return m.group(0)
|
|
132
|
+
name = m.group(1).strip()
|
|
133
|
+
real = name if name in fields else lookup.get(name.lower())
|
|
134
|
+
if real is None:
|
|
135
|
+
return m.group(0)
|
|
136
|
+
if real not in slots:
|
|
137
|
+
slots[real] = _field_token(len(slots))
|
|
138
|
+
return slots[real]
|
|
139
|
+
|
|
140
|
+
src = re.sub(r"\[([^\[\]]+)\]", sub_bracket, src)
|
|
141
|
+
try:
|
|
142
|
+
tree = ast.parse(src, mode="eval")
|
|
143
|
+
except SyntaxError as e:
|
|
144
|
+
raise CalcError("That is not a valid expression: %s" % (e.msg or e))
|
|
145
|
+
byslot = {v: k for k, v in slots.items()}
|
|
146
|
+
|
|
147
|
+
def check(node):
|
|
148
|
+
for n in ast.walk(node):
|
|
149
|
+
if isinstance(n, ast.Name):
|
|
150
|
+
if n.id.startswith("__f") and n.id in byslot:
|
|
151
|
+
continue
|
|
152
|
+
if n.id in NAMES or n.id in FUNCS:
|
|
153
|
+
continue
|
|
154
|
+
if n.id in fields or n.id.lower() in lookup:
|
|
155
|
+
continue
|
|
156
|
+
raise CalcError("%r is not a field or a function. Write a field as !NAME!." % n.id)
|
|
157
|
+
elif isinstance(n, ast.List):
|
|
158
|
+
# What a [NAME] that matched no field parses as. Saying "List is not allowed"
|
|
159
|
+
# about a typo in a field name would send somebody looking in the wrong place.
|
|
160
|
+
if len(n.elts) == 1 and isinstance(n.elts[0], ast.Name):
|
|
161
|
+
raise CalcError("[%s] is not a field in this layer." % n.elts[0].id)
|
|
162
|
+
raise CalcError("A list is not allowed in a field calculation.")
|
|
163
|
+
elif isinstance(n, ast.Attribute):
|
|
164
|
+
if n.attr not in STR_METHODS:
|
|
165
|
+
raise CalcError(".%s is not allowed; string methods are: %s"
|
|
166
|
+
% (n.attr, ", ".join(sorted(STR_METHODS))))
|
|
167
|
+
elif isinstance(n, ast.Call):
|
|
168
|
+
f = n.func
|
|
169
|
+
if not ((isinstance(f, ast.Name) and f.id in FUNCS)
|
|
170
|
+
or (isinstance(f, ast.Attribute) and f.attr in STR_METHODS)):
|
|
171
|
+
raise CalcError("Only these functions are available: %s"
|
|
172
|
+
% ", ".join(sorted(FUNCS)))
|
|
173
|
+
if n.keywords:
|
|
174
|
+
raise CalcError("Keyword arguments are not supported.")
|
|
175
|
+
elif not isinstance(n, (ast.Expression, ast.BinOp, ast.UnaryOp, ast.BoolOp,
|
|
176
|
+
ast.Compare, ast.IfExp, ast.Constant, ast.Load,
|
|
177
|
+
ast.Subscript, ast.Slice, ast.Tuple, ast.JoinedStr,
|
|
178
|
+
ast.FormattedValue, ast.operator, ast.unaryop,
|
|
179
|
+
ast.boolop, ast.cmpop)):
|
|
180
|
+
raise CalcError("%s is not allowed in a field calculation."
|
|
181
|
+
% type(n).__name__)
|
|
182
|
+
|
|
183
|
+
check(tree)
|
|
184
|
+
|
|
185
|
+
def ev(n, row):
|
|
186
|
+
t = type(n)
|
|
187
|
+
if t is ast.Expression:
|
|
188
|
+
return ev(n.body, row)
|
|
189
|
+
if t is ast.Constant:
|
|
190
|
+
return n.value
|
|
191
|
+
if t is ast.Name:
|
|
192
|
+
if n.id in byslot:
|
|
193
|
+
return row.get(byslot[n.id])
|
|
194
|
+
if n.id in NAMES:
|
|
195
|
+
return NAMES[n.id]
|
|
196
|
+
if n.id in FUNCS:
|
|
197
|
+
return FUNCS[n.id]
|
|
198
|
+
return row.get(n.id if n.id in fields else lookup[n.id.lower()])
|
|
199
|
+
if t is ast.BinOp:
|
|
200
|
+
a, b = ev(n.left, row), ev(n.right, row)
|
|
201
|
+
if a is None or b is None:
|
|
202
|
+
return None
|
|
203
|
+
if isinstance(n.op, ast.Pow) and isinstance(b, (int, float)) and abs(b) > _MAX_POW:
|
|
204
|
+
raise CalcError("An exponent over %d is refused." % _MAX_POW)
|
|
205
|
+
if isinstance(n.op, ast.Mult) and (isinstance(a, str) or isinstance(b, str)):
|
|
206
|
+
s, k = (a, b) if isinstance(a, str) else (b, a)
|
|
207
|
+
if isinstance(k, int) and len(s) * k > _MAX_LEN:
|
|
208
|
+
raise CalcError("That would make a string over %d characters." % _MAX_LEN)
|
|
209
|
+
return _BIN[type(n.op)](a, b)
|
|
210
|
+
if t is ast.UnaryOp:
|
|
211
|
+
v = ev(n.operand, row)
|
|
212
|
+
if v is None and not isinstance(n.op, ast.Not):
|
|
213
|
+
return None
|
|
214
|
+
return _UN[type(n.op)](v)
|
|
215
|
+
if t is ast.BoolOp:
|
|
216
|
+
if isinstance(n.op, ast.And):
|
|
217
|
+
v = True
|
|
218
|
+
for x in n.values:
|
|
219
|
+
v = ev(x, row)
|
|
220
|
+
if not v:
|
|
221
|
+
return v
|
|
222
|
+
return v
|
|
223
|
+
v = False
|
|
224
|
+
for x in n.values:
|
|
225
|
+
v = ev(x, row)
|
|
226
|
+
if v:
|
|
227
|
+
return v
|
|
228
|
+
return v
|
|
229
|
+
if t is ast.Compare:
|
|
230
|
+
left = ev(n.left, row)
|
|
231
|
+
for op, c in zip(n.ops, n.comparators):
|
|
232
|
+
right = ev(c, row)
|
|
233
|
+
# Only ==, != and `is` have an answer about a null; anything else is false.
|
|
234
|
+
if (left is None or right is None) and not isinstance(
|
|
235
|
+
op, (ast.Eq, ast.NotEq, ast.Is, ast.IsNot)):
|
|
236
|
+
return False
|
|
237
|
+
if not _CMP[type(op)](left, right):
|
|
238
|
+
return False
|
|
239
|
+
left = right
|
|
240
|
+
return True
|
|
241
|
+
if t is ast.IfExp:
|
|
242
|
+
return ev(n.body, row) if ev(n.test, row) else ev(n.orelse, row)
|
|
243
|
+
if t is ast.Call:
|
|
244
|
+
if isinstance(n.func, ast.Attribute):
|
|
245
|
+
obj = ev(n.func.value, row)
|
|
246
|
+
if obj is None:
|
|
247
|
+
return None
|
|
248
|
+
if not isinstance(obj, str):
|
|
249
|
+
raise CalcError(".%s() needs text, and this row has %r"
|
|
250
|
+
% (n.func.attr, obj))
|
|
251
|
+
args = [ev(a, row) for a in n.args]
|
|
252
|
+
# zfill(10**9) and friends build the string before anything could cap it.
|
|
253
|
+
if n.func.attr in ("zfill", "center", "ljust", "rjust") and any(
|
|
254
|
+
isinstance(a, int) and a > _MAX_LEN for a in args):
|
|
255
|
+
raise CalcError("A width over %d is refused." % _MAX_LEN)
|
|
256
|
+
return getattr(obj, n.func.attr)(*args)
|
|
257
|
+
fname = n.func.id
|
|
258
|
+
if fname == "iif":
|
|
259
|
+
if len(n.args) != 3:
|
|
260
|
+
raise CalcError("iif() takes three values: iif(test, a, b).")
|
|
261
|
+
return ev(n.args[1], row) if ev(n.args[0], row) else ev(n.args[2], row)
|
|
262
|
+
args = [ev(a, row) for a in n.args]
|
|
263
|
+
if fname in _NULL_SAFE and any(a is None for a in args):
|
|
264
|
+
return None
|
|
265
|
+
if fname in ("upper", "lower", "trim"):
|
|
266
|
+
if len(args) != 1:
|
|
267
|
+
raise CalcError("%s() takes one value." % fname)
|
|
268
|
+
return FUNCS[fname](str(args[0]))
|
|
269
|
+
return FUNCS[fname](*args)
|
|
270
|
+
if t is ast.Attribute:
|
|
271
|
+
raise CalcError(".%s must be called, as .%s()" % (n.attr, n.attr))
|
|
272
|
+
if t is ast.Subscript:
|
|
273
|
+
obj = ev(n.value, row)
|
|
274
|
+
if obj is None:
|
|
275
|
+
return None
|
|
276
|
+
s = n.slice
|
|
277
|
+
if type(s) is ast.Slice:
|
|
278
|
+
return obj[slice(*(ev(x, row) if x is not None else None
|
|
279
|
+
for x in (s.lower, s.upper, s.step)))]
|
|
280
|
+
return obj[ev(s, row)]
|
|
281
|
+
if t is ast.Tuple:
|
|
282
|
+
return tuple(ev(x, row) for x in n.elts)
|
|
283
|
+
if t is ast.JoinedStr:
|
|
284
|
+
# A null anywhere makes the whole string null, as text concatenation does in a
|
|
285
|
+
# database, rather than writing the word "None" into somebody's labels.
|
|
286
|
+
parts = [ev(v, row) for v in n.values]
|
|
287
|
+
if any(p is None for p in parts):
|
|
288
|
+
return None
|
|
289
|
+
return "".join(str(p) for p in parts)
|
|
290
|
+
if t is ast.FormattedValue:
|
|
291
|
+
v = ev(n.value, row)
|
|
292
|
+
if v is None:
|
|
293
|
+
return None
|
|
294
|
+
spec = ev(n.format_spec, row) if n.format_spec is not None else ""
|
|
295
|
+
if any(int(d) > _MAX_LEN for d in re.findall(r"\d+", spec)):
|
|
296
|
+
raise CalcError("A width over %d is refused." % _MAX_LEN)
|
|
297
|
+
return format(v, spec)
|
|
298
|
+
raise CalcError("%s is not allowed in a field calculation." % t.__name__)
|
|
299
|
+
|
|
300
|
+
def run(row):
|
|
301
|
+
try:
|
|
302
|
+
return ev(tree, row)
|
|
303
|
+
except CalcError:
|
|
304
|
+
raise
|
|
305
|
+
except Exception as e: # noqa: BLE001 - row-level, named
|
|
306
|
+
raise CalcError("%s: %s" % (type(e).__name__, e))
|
|
307
|
+
|
|
308
|
+
return run
|