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.
Files changed (62) hide show
  1. {env_able-2.0.0 → env_able-2.1.0}/.gitignore +1 -0
  2. {env_able-2.0.0 → env_able-2.1.0}/CHANGELOG.md +35 -0
  3. {env_able-2.0.0 → env_able-2.1.0}/PKG-INFO +44 -1
  4. {env_able-2.0.0 → env_able-2.1.0}/README.md +43 -0
  5. {env_able-2.0.0 → env_able-2.1.0}/pyproject.toml +1 -1
  6. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/__init__.py +3 -2
  7. env_able-2.1.0/src/env_able/atlas/calc.py +308 -0
  8. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-guide.md +141 -19
  9. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/env-able-library.md +14 -3
  10. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/index.html +24879 -23605
  11. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/server.py +13975 -13051
  12. env_able-2.1.0/src/env_able/remote.py +326 -0
  13. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/vector.py +137 -0
  14. env_able-2.0.0/src/env_able/remote.py +0 -203
  15. {env_able-2.0.0 → env_able-2.1.0}/LICENSE +0 -0
  16. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_gdal_env.py +0 -0
  17. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_gdal_utils.py +0 -0
  18. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_pipeline.py +0 -0
  19. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/_result.py +0 -0
  20. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/aggregate.py +0 -0
  21. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/__init__.py +0 -0
  22. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/aix.py +0 -0
  23. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/brandmark.py +0 -0
  24. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/claude_bridge.py +0 -0
  25. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/client.py +0 -0
  26. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/compliance.html +0 -0
  27. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/databricks.py +0 -0
  28. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/debug.py +0 -0
  29. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/desktop.py +0 -0
  30. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/atlas-layout-view.md +0 -0
  31. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/docs/cartographic-judgment.md +0 -0
  32. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/edits.py +0 -0
  33. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/eirstyle.py +0 -0
  34. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/env_layers.py +0 -0
  35. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/landing.html +0 -0
  36. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/naming.py +0 -0
  37. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/palette.py +0 -0
  38. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/project.py +0 -0
  39. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/session.py +0 -0
  40. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/splash.html +0 -0
  41. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Landscape.pagx +0 -0
  42. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Summary A4 Portrait.pagx +0 -0
  43. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Landscape.pagx +0 -0
  44. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/templates/Title Bar A4 Portrait.pagx +0 -0
  45. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/HTML-TO-IMAGE-LICENSE.txt +0 -0
  46. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/MAPLIBRE-LICENSE.txt +0 -0
  47. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/PROTOMAPS-LICENSE.txt +0 -0
  48. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/ROBOTO-LICENSE.txt +0 -0
  49. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/html-to-image.js +0 -0
  50. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.css +0 -0
  51. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/maplibre-gl.js +0 -0
  52. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/pmtiles.js +0 -0
  53. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/protomaps-basemaps.js +0 -0
  54. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-italic.woff2 +0 -0
  55. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-400-normal.woff2 +0 -0
  56. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-500-normal.woff2 +0 -0
  57. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/atlas/vendor/roboto-latin-700-normal.woff2 +0 -0
  58. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/classify.py +0 -0
  59. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/layout.py +0 -0
  60. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/morph.py +0 -0
  61. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/pull.py +0 -0
  62. {env_able-2.0.0 → env_able-2.1.0}/src/env_able/stream.py +0 -0
@@ -172,3 +172,4 @@ tests/*
172
172
  # ~175 MB of it. Only the README that says what each scenario is for is kept.
173
173
  tests/maps/*
174
174
  !tests/maps/README.md
175
+ /incubator
@@ -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.0.0
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "env-able"
7
- version = "2.0.0"
7
+ version = "2.1.0"
8
8
  description = "AI-ready GIS toolkit for energy and subsurface workflows."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.8"
@@ -1,4 +1,4 @@
1
- __version__ = "2.0.0"
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