adapt-server 0.2.6__tar.gz → 0.2.8__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 (119) hide show
  1. {adapt_server-0.2.6 → adapt_server-0.2.8}/PKG-INFO +2 -2
  2. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/__init__.py +1 -1
  3. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/discovery.py +31 -0
  4. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/base.py +15 -0
  5. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/dataset_plugin.py +115 -12
  6. adapt_server-0.2.8/adapt/plugins/excel_plugin.py +279 -0
  7. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/datatable.html +47 -21
  8. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/PKG-INFO +2 -2
  9. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/SOURCES.txt +2 -0
  10. {adapt_server-0.2.6 → adapt_server-0.2.8}/pyproject.toml +2 -2
  11. adapt_server-0.2.8/tests/test_datatable_template.py +120 -0
  12. adapt_server-0.2.8/tests/test_excel_plugin.py +265 -0
  13. adapt_server-0.2.6/adapt/plugins/excel_plugin.py +0 -128
  14. {adapt_server-0.2.6 → adapt_server-0.2.8}/LICENSE +0 -0
  15. {adapt_server-0.2.6 → adapt_server-0.2.8}/README.md +0 -0
  16. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/__main__.py +0 -0
  17. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/__init__.py +0 -0
  18. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/api_keys.py +0 -0
  19. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/audit_logs.py +0 -0
  20. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/cache.py +0 -0
  21. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/groups.py +0 -0
  22. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/locks.py +0 -0
  23. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/models.py +0 -0
  24. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/permissions.py +0 -0
  25. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/resources.py +0 -0
  26. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/ui.py +0 -0
  27. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/admin/users.py +0 -0
  28. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/api_keys.py +0 -0
  29. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/app.py +0 -0
  30. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/audit.py +0 -0
  31. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/__init__.py +0 -0
  32. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/dependencies.py +0 -0
  33. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/password.py +0 -0
  34. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/routes.py +0 -0
  35. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/auth/session.py +0 -0
  36. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/cache.py +0 -0
  37. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/cli.py +0 -0
  38. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/__init__.py +0 -0
  39. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/addsuperuser.py +0 -0
  40. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/__init__.py +0 -0
  41. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/add_to_group.py +0 -0
  42. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_group.py +0 -0
  43. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_permissions.py +0 -0
  44. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/create_user.py +0 -0
  45. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/delete_group.py +0 -0
  46. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/delete_user.py +0 -0
  47. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_groups.py +0 -0
  48. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_resources.py +0 -0
  49. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/list_users.py +0 -0
  50. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/admin/remove_from_group.py +0 -0
  51. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/check.py +0 -0
  52. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/list_endpoints.py +0 -0
  53. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/passwords.py +0 -0
  54. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/reindex.py +0 -0
  55. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/commands/serve.py +0 -0
  56. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/config.py +0 -0
  57. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/locks.py +0 -0
  58. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/mcp.py +0 -0
  59. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/models.py +0 -0
  60. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/permissions.py +0 -0
  61. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/__init__.py +0 -0
  62. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/csv_plugin.py +0 -0
  63. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/file_plugin.py +0 -0
  64. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/html_plugin.py +0 -0
  65. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/markdown_plugin.py +0 -0
  66. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/media_plugin.py +0 -0
  67. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/parquet_plugin.py +0 -0
  68. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/plugins/python_plugin.py +0 -0
  69. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/routes.py +0 -0
  70. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/routes_search.py +0 -0
  71. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/search.py +0 -0
  72. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/security.py +0 -0
  73. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/security_urls.py +0 -0
  74. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/admin/app.js +0 -0
  75. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/admin/style.css +0 -0
  76. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/sortable-tables.css +0 -0
  77. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/static/sortable-tables.js +0 -0
  78. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/storage.py +0 -0
  79. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/admin/index.html +0 -0
  80. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/admin_base.html +0 -0
  81. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/base.html +0 -0
  82. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/landing.html +0 -0
  83. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/login.html +0 -0
  84. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/media_gallery.html +0 -0
  85. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/media_player.html +0 -0
  86. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/profile.html +0 -0
  87. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/templates/search_results.html +0 -0
  88. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/utils/__init__.py +0 -0
  89. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt/utils/query.py +0 -0
  90. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/dependency_links.txt +0 -0
  91. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/entry_points.txt +0 -0
  92. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/requires.txt +0 -0
  93. {adapt_server-0.2.6 → adapt_server-0.2.8}/adapt_server.egg-info/top_level.txt +0 -0
  94. {adapt_server-0.2.6 → adapt_server-0.2.8}/setup.cfg +0 -0
  95. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_admin.py +0 -0
  96. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_auth.py +0 -0
  97. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_cache.py +0 -0
  98. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_cli_integration.py +0 -0
  99. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_config.py +0 -0
  100. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_constraints.py +0 -0
  101. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_csv_plugin.py +0 -0
  102. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_dataset_plugin.py +0 -0
  103. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_dependencies.py +0 -0
  104. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_discovery.py +0 -0
  105. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_file_plugin.py +0 -0
  106. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_html_plugin.py +0 -0
  107. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_integration.py +0 -0
  108. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_locks.py +0 -0
  109. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_markdown_plugin.py +0 -0
  110. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_mcp.py +0 -0
  111. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_on_delete.py +0 -0
  112. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_parquet_plugin.py +0 -0
  113. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_phase3.py +0 -0
  114. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_plugin_interface.py +0 -0
  115. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_python_plugin.py +0 -0
  116. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_readonly.py +0 -0
  117. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_routes.py +0 -0
  118. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_search.py +0 -0
  119. {adapt_server-0.2.6 → adapt_server-0.2.8}/tests/test_security_urls.py +0 -0
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: adapt-server
3
- Version: 0.2.6
3
+ Version: 0.2.8
4
4
  Summary: Adaptive file-backed FastAPI server that turns datasets into CRUD APIs and UIs.
5
5
  Author-email: notesofcliff <notesofcliff@gmail.com>
6
6
  License-Expression: MIT
7
- Project-URL: Homepage, https://github.com/McInci/adapt
7
+ Project-URL: Homepage, https://www.mcindi.com/software/adapt/
8
8
  Project-URL: Repository, https://github.com/McIndi/adapt
9
9
  Project-URL: Issues, https://github.com/McIndi/adapt/issues
10
10
  Keywords: fastapi,api,csv,excel,parquet,markdown,media
@@ -1,3 +1,3 @@
1
1
  """Adapt package namespace."""
2
2
 
3
- __version__ = "0.2.6"
3
+ __version__ = "0.2.8"
@@ -23,9 +23,31 @@ class DatasetResource:
23
23
  schema_path: Path
24
24
  ui_path: Path
25
25
  plugin_name: str
26
+ options_path: Path | None = None
26
27
  metadata: dict[str, Any] = field(default_factory=dict)
27
28
 
28
29
 
30
+ def read_resource_options(options_path: Path) -> dict[str, Any]:
31
+ """Read a companion `.options.json` file, tolerating absence and bad content.
32
+
33
+ Options are hand-written, so a malformed file must not take the server down —
34
+ it is logged and ignored, leaving the resource on its defaults.
35
+ """
36
+ if not options_path.exists():
37
+ return {}
38
+ try:
39
+ with options_path.open(encoding="utf-8") as f:
40
+ data = json.load(f)
41
+ except (OSError, json.JSONDecodeError) as exc:
42
+ logger.error("Ignoring invalid options file %s: %s", options_path, exc)
43
+ return {}
44
+ if not isinstance(data, dict):
45
+ logger.error("Ignoring options file %s: expected a JSON object", options_path)
46
+ return {}
47
+ logger.debug("Loaded options from %s: %s", options_path, data)
48
+ return data
49
+
50
+
29
51
  def should_ignore(path: Path) -> bool:
30
52
  """Check if a path should be ignored during discovery.
31
53
 
@@ -91,9 +113,16 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
91
113
  base_path = adapt_dir / path.relative_to(root)
92
114
  schema_path = base_path.with_suffix(f"{suffix}.schema.json")
93
115
  ui_path = base_path.with_suffix(f"{suffix}.index.html")
116
+ options_path = base_path.with_suffix(f"{suffix}.options.json")
94
117
 
95
118
  descriptor.schema_path = schema_path
96
119
  descriptor.ui_path = ui_path
120
+ descriptor.options_path = options_path
121
+
122
+ # Options must be applied before companion files are generated, so a
123
+ # schema derived from an overridden header row is written out correctly.
124
+ descriptor.metadata["options"] = read_resource_options(options_path)
125
+ plugin.apply_options(descriptor)
97
126
 
98
127
  plugin.generate_companion_files(descriptor)
99
128
 
@@ -103,6 +132,7 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
103
132
  resource_type=descriptor.resource_type,
104
133
  schema_path=schema_path,
105
134
  ui_path=ui_path,
135
+ options_path=options_path,
106
136
  plugin_name=plugin_cls.__name__,
107
137
  metadata=descriptor.metadata,
108
138
  )
@@ -115,4 +145,5 @@ def discover_resources(root: Path, config: AdaptConfig) -> list[DatasetResource]
115
145
  __all__ = [
116
146
  "DatasetResource",
117
147
  "discover_resources",
148
+ "read_resource_options",
118
149
  ]
@@ -37,6 +37,7 @@ class ResourceDescriptor:
37
37
  resource_type: str
38
38
  schema_path: Path | None = None
39
39
  ui_path: Path | None = None
40
+ options_path: Path | None = None
40
41
  metadata: dict[str, Any] = field(default_factory=dict)
41
42
 
42
43
 
@@ -77,6 +78,20 @@ class Plugin(ABC):
77
78
  """Write data/content for the resource."""
78
79
  ...
79
80
 
81
+ def apply_options(self, descriptor: ResourceDescriptor) -> None:
82
+ """Apply per-resource options to a descriptor after discovery has located them.
83
+
84
+ Options come from the companion `.adapt/<name>[.<sub_namespace>].options.json`
85
+ file and are already parsed into `descriptor.metadata["options"]`. This runs
86
+ after `load()` because `load()` receives only a path and cannot know where the
87
+ companion directory is; it runs before `generate_companion_files()` so that any
88
+ derived schema reflects the options.
89
+
90
+ The default does nothing. Plugins override this to honour the options they
91
+ support.
92
+ """
93
+ logger.debug(f"No options to apply for resource: {descriptor.path}")
94
+
80
95
  def get_route_configs(self, descriptor: ResourceDescriptor) -> list[tuple[str, APIRouter]]:
81
96
  """Return list of (prefix, router) tuples for mounting routes."""
82
97
  logger.debug(f"Getting route configs for resource: {descriptor.path}")
@@ -19,6 +19,67 @@ from .base import Plugin, ResourceDescriptor, PluginContext, SearchDocument, ens
19
19
 
20
20
  logger = logging.getLogger(__name__)
21
21
 
22
+ #: 1-based sheet/file row that holds the column names, unless a resource
23
+ #: overrides it via `header_row` in its companion `.options.json`.
24
+ DEFAULT_HEADER_ROW = 1
25
+
26
+ #: Marks a companion schema.json as machine-written, so Adapt may refresh it when
27
+ #: the resource's shape changes. A schema without this key is treated as
28
+ #: hand-maintained and is never overwritten.
29
+ GENERATED_MARKER = "generated_by"
30
+ GENERATED_BY_ADAPT = "adapt"
31
+
32
+
33
+ def resolve_header_row(resource: ResourceDescriptor) -> int:
34
+ """Return the 1-based header row for a resource, falling back to the default.
35
+
36
+ A bad override is logged and ignored rather than raised: the value is
37
+ hand-written into a companion file, and a typo should not break the resource.
38
+ """
39
+ raw = resource.metadata.get("header_row", DEFAULT_HEADER_ROW)
40
+ try:
41
+ header_row = int(raw)
42
+ except (TypeError, ValueError):
43
+ logger.error("Ignoring non-integer header_row %r for %s", raw, resource.path)
44
+ return DEFAULT_HEADER_ROW
45
+ if header_row < 1:
46
+ logger.error("Ignoring header_row %d for %s: must be >= 1", header_row, resource.path)
47
+ return DEFAULT_HEADER_ROW
48
+ return header_row
49
+
50
+
51
+ def write_generated_schema(path: Path, schema: dict[str, Any]) -> bool:
52
+ """Write an auto-derived schema, refreshing a stale one but sparing hand-edits.
53
+
54
+ `ensure_file` would leave a stale schema in place forever, which matters once a
55
+ resource can be re-shaped by its options: a workbook switched to `header_row: 3`
56
+ would keep serving the old column names to the UI while the API returned the new
57
+ ones, and the table would render empty.
58
+
59
+ Returns True when the file was written.
60
+ """
61
+ payload = {GENERATED_MARKER: GENERATED_BY_ADAPT, **schema}
62
+ if path.exists():
63
+ try:
64
+ existing = json.loads(path.read_text(encoding="utf-8"))
65
+ except (OSError, json.JSONDecodeError) as exc:
66
+ logger.warning("Leaving unreadable schema %s in place: %s", path, exc)
67
+ return False
68
+ if not isinstance(existing, dict) or existing.get(GENERATED_MARKER) != GENERATED_BY_ADAPT:
69
+ # Schemas written before the marker existed carry no provenance. One that
70
+ # still matches what we derive was ours, so adopt it; anything else is
71
+ # assumed hand-maintained and left alone.
72
+ if existing != schema:
73
+ logger.debug("Schema %s is hand-maintained; not regenerating", path)
74
+ return False
75
+ logger.debug("Adopting pre-marker generated schema %s", path)
76
+ if existing == payload:
77
+ return False
78
+ logger.info("Refreshing stale generated schema %s", path)
79
+ path.parent.mkdir(parents=True, exist_ok=True)
80
+ path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
81
+ return True
82
+
22
83
 
23
84
  def _guess_type(value: str | None) -> str:
24
85
  """Guess the data type of a string value."""
@@ -64,7 +125,12 @@ def _build_columns(header: Sequence[str], sample: Sequence[str | None]) -> dict[
64
125
 
65
126
  class DatasetPlugin(Plugin):
66
127
  """Base plugin for dataset-like resources (CSV, Excel, etc.)."""
67
-
128
+
129
+ #: (path, sub_namespace) pairs already reported by `_warn_on_column_drift`,
130
+ #: so a misconfigured resource warns once rather than on every request.
131
+ _drift_warned: set[tuple[str, str]] = set()
132
+
133
+
68
134
  def load(self, path: Path) -> ResourceDescriptor:
69
135
  """Load a resource descriptor for the dataset."""
70
136
  header, sample = self._get_header_and_sample(path)
@@ -75,6 +141,22 @@ class DatasetPlugin(Plugin):
75
141
  logger.debug("Loaded descriptor for %s", path)
76
142
  return descriptor
77
143
 
144
+ def derive_schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
145
+ """Build the schema from the descriptor's parsed header and sample row.
146
+
147
+ Kept separate from `schema()` because `schema()` prefers whatever is already
148
+ on disk; regenerating a companion file has to compare against a freshly
149
+ derived schema or it would only ever rewrite a stale file with itself.
150
+ """
151
+ header = resource.metadata.get("header", [])
152
+ sample = resource.metadata.get("sample_row", [])
153
+ return {
154
+ "type": "object",
155
+ "name": resource.path.stem,
156
+ "primary_key": resource.metadata.get("primary_key"),
157
+ "columns": _build_columns(header, sample),
158
+ }
159
+
78
160
  def schema(self, resource: ResourceDescriptor) -> dict[str, Any]:
79
161
  """Get the schema for the resource, with caching."""
80
162
  sub_namespace = resource.metadata.get("sub_namespace", "")
@@ -86,16 +168,10 @@ class DatasetPlugin(Plugin):
86
168
  if resource.schema_path and resource.schema_path.exists():
87
169
  with resource.schema_path.open() as f:
88
170
  schema = json.load(f)
171
+ # Internal bookkeeping, not part of the published schema.
172
+ schema.pop(GENERATED_MARKER, None)
89
173
  else:
90
- header = resource.metadata.get("header", [])
91
- sample = resource.metadata.get("sample_row", [])
92
- columns = _build_columns(header, sample)
93
- schema = {
94
- "type": "object",
95
- "name": resource.path.stem,
96
- "primary_key": resource.metadata.get("primary_key"),
97
- "columns": columns,
98
- }
174
+ schema = self.derive_schema(resource)
99
175
  set_cache(cache_key, schema, ttl_seconds=3600, resource=str(resource.path)) # 1 hour TTL
100
176
  logger.debug("Generated schema for %s", resource.path)
101
177
  return schema
@@ -105,6 +181,7 @@ class DatasetPlugin(Plugin):
105
181
  header = resource.metadata.get("header", [])
106
182
  schema = self.schema(resource)
107
183
  columns = schema.get("columns", {})
184
+ self._warn_on_column_drift(resource, header, columns)
108
185
  raw_rows = self._read_raw_rows(resource)
109
186
 
110
187
  # Apply Row-Level Security
@@ -170,6 +247,31 @@ class DatasetPlugin(Plugin):
170
247
  logger.debug("Indexed %d rows from %s", len(documents), resource.path)
171
248
  return documents
172
249
 
250
+ def _warn_on_column_drift(
251
+ self, resource: ResourceDescriptor, header: Sequence[str], columns: dict[str, Any]
252
+ ) -> None:
253
+ """Warn once when the schema's columns don't match the parsed header.
254
+
255
+ `read()` keys its row dicts off the header parsed from the file, while the
256
+ UI builds its table from the schema. If a hand-edited schema.json names
257
+ different columns the two never meet, and the table renders empty with no
258
+ error anywhere — so say something instead of failing silently.
259
+ """
260
+ if not columns or not header:
261
+ return
262
+ if list(columns) == list(header):
263
+ return
264
+ key = (str(resource.path), resource.metadata.get("sub_namespace", ""))
265
+ if key in self._drift_warned:
266
+ return
267
+ self._drift_warned.add(key)
268
+ logger.warning(
269
+ "Schema columns %s do not match the parsed header %s for %s%s; rows are keyed "
270
+ "by the header, so mismatched columns will render empty in the UI.",
271
+ list(columns), list(header), resource.path,
272
+ f" [{key[1]}]" if key[1] else "",
273
+ )
274
+
173
275
  def _convert_value(self, value: str, col_type: str) -> Any:
174
276
  """Convert a string value to the appropriate type."""
175
277
  if col_type == "integer":
@@ -448,8 +550,9 @@ class DatasetPlugin(Plugin):
448
550
  logger.debug(f"Generating companion files for {descriptor.path}")
449
551
  # Generate schema.json
450
552
  if descriptor.schema_path:
451
- schema = self.schema(descriptor)
452
- ensure_file(descriptor.schema_path, json.dumps(schema, indent=2))
553
+ if write_generated_schema(descriptor.schema_path, self.derive_schema(descriptor)):
554
+ # The cached copy describes the shape we just replaced.
555
+ invalidate_cache(str(descriptor.path))
453
556
  logger.debug(f"Generated schema.json at {descriptor.schema_path}")
454
557
 
455
558
  # Generate index.html using datatable.html template
@@ -0,0 +1,279 @@
1
+ from __future__ import annotations
2
+ import logging
3
+ from adapt.cache import get_cache, set_cache, invalidate_cache
4
+
5
+ from itertools import zip_longest
6
+ from pathlib import Path
7
+ from typing import Any, Iterator, Sequence
8
+
9
+ from openpyxl import load_workbook
10
+
11
+ from .base import ResourceDescriptor, atomic_write
12
+ from .dataset_plugin import DEFAULT_HEADER_ROW, DatasetPlugin, resolve_header_row
13
+
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _is_blank(value: Any) -> bool:
19
+ """True for a cell holding nothing a reader would call a value."""
20
+ return value is None or (isinstance(value, str) and not value.strip())
21
+
22
+
23
+ def _merge_formula(value: Any, formula: Any) -> Any:
24
+ """Fall back to a cell's formula text when it has no cached result.
25
+
26
+ openpyxl reports only the value Excel last calculated and stored in the file.
27
+ A workbook written by openpyxl itself carries no cached results at all, so
28
+ under `data_only=True` every formula cell reads back as None and the column
29
+ renders blank. Surfacing the formula is worse than a number and much better
30
+ than an empty column with nothing to explain it.
31
+ """
32
+ if value is not None:
33
+ return value
34
+ if isinstance(formula, str) and formula.startswith("="):
35
+ return formula
36
+ return value
37
+
38
+
39
+ def _merged_rows(value_sheet: Any, formula_sheet: Any) -> Iterator[tuple[int, list[Any]]]:
40
+ """Yield (1-based row number, cells) for a sheet, formulas filled in.
41
+
42
+ Both sheets are streamed in lockstep so a large workbook is never fully
43
+ materialised. `zip_longest` guards the case where the two passes disagree on
44
+ width rather than silently truncating a row.
45
+ """
46
+ paired = zip_longest(
47
+ value_sheet.iter_rows(values_only=True),
48
+ formula_sheet.iter_rows(values_only=True),
49
+ fillvalue=(),
50
+ )
51
+ for row_number, (value_row, formula_row) in enumerate(paired, start=1):
52
+ yield row_number, [
53
+ _merge_formula(value, formula)
54
+ for value, formula in zip_longest(value_row or (), formula_row or ())
55
+ ]
56
+
57
+
58
+ def _extract_header_and_sample(
59
+ value_sheet: Any, formula_sheet: Any, header_row: int
60
+ ) -> tuple[list[Any], list[Any]]:
61
+ """Return the header cells and the first non-blank row beneath them."""
62
+ header: list[Any] = []
63
+ sample: list[Any] = []
64
+ for row_number, cells in _merged_rows(value_sheet, formula_sheet):
65
+ if row_number < header_row:
66
+ continue
67
+ if row_number == header_row:
68
+ header = cells
69
+ continue
70
+ if all(_is_blank(cell) for cell in cells):
71
+ continue
72
+ sample = cells
73
+ break
74
+ return header, sample
75
+
76
+
77
+ class ExcelPlugin(DatasetPlugin):
78
+ @property
79
+ def resource_type(self) -> str:
80
+ """Return the resource type string."""
81
+ return "excel"
82
+
83
+ def detect(self, path: Path) -> bool:
84
+ """Detect if the path is an Excel file.
85
+
86
+ Args:
87
+ path: The file path to check.
88
+
89
+ Returns:
90
+ True if the file has .xlsx extension, False otherwise.
91
+ """
92
+ return path.suffix.lower() == ".xlsx"
93
+
94
+ def load(self, path: Path) -> Sequence[ResourceDescriptor]:
95
+ """Load Excel file and create descriptors for each worksheet.
96
+
97
+ Every sheet is read at the default header row; a sheet that overrides it
98
+ is re-read later by `apply_options`, once discovery has located the
99
+ companion options file.
100
+
101
+ Args:
102
+ path: The path to the Excel file.
103
+
104
+ Returns:
105
+ A sequence of ResourceDescriptors, one for each worksheet.
106
+ """
107
+ logger.debug(f"Loading Excel file: {path}")
108
+ descriptors = []
109
+ with self._open_pair(path) as (values, formulas):
110
+ for sheet_name in values.sheetnames:
111
+ logger.debug(f"Processing sheet: {sheet_name}")
112
+ descriptor = ResourceDescriptor(path=path, resource_type=self.resource_type)
113
+ descriptor.metadata["primary_key"] = "_row_id"
114
+ descriptor.metadata["sub_namespace"] = sheet_name
115
+ self._set_header_metadata(
116
+ descriptor, values[sheet_name], formulas[sheet_name], DEFAULT_HEADER_ROW
117
+ )
118
+ descriptors.append(descriptor)
119
+ logger.info(f"Loaded {len(descriptors)} worksheets from Excel file: {path}")
120
+ return descriptors
121
+
122
+ def apply_options(self, descriptor: ResourceDescriptor) -> None:
123
+ """Honour a `header_row` override from the companion options file.
124
+
125
+ Sheets often open with a title banner rather than column names, which
126
+ would otherwise be parsed as the header and turn a sentence into a column
127
+ name. Setting `{"header_row": 3}` points Adapt at the real header.
128
+ """
129
+ options = descriptor.metadata.get("options") or {}
130
+ if "header_row" not in options:
131
+ return
132
+
133
+ descriptor.metadata["header_row"] = options["header_row"]
134
+ header_row = resolve_header_row(descriptor)
135
+ descriptor.metadata["header_row"] = header_row
136
+ if header_row == DEFAULT_HEADER_ROW:
137
+ return
138
+
139
+ sheet_name = descriptor.metadata["sub_namespace"]
140
+ logger.info(
141
+ "Re-reading header of %s [%s] from row %d", descriptor.path, sheet_name, header_row
142
+ )
143
+ with self._open_pair(descriptor.path) as (values, formulas):
144
+ self._set_header_metadata(
145
+ descriptor, values[sheet_name], formulas[sheet_name], header_row
146
+ )
147
+ # Rows cached under the previous header row describe a different shape.
148
+ invalidate_cache(str(descriptor.path))
149
+
150
+ def _read_raw_rows(self, resource: ResourceDescriptor) -> list[list[str]]:
151
+ """Read raw rows from the Excel worksheet.
152
+
153
+ Rows that are entirely empty are dropped, and the sheet's extent is taken
154
+ from the rows that actually carry values rather than from the declared
155
+ `<dimension>`. Writers routinely leave styled-but-valueless cells behind
156
+ (a whole formatted column, say), which inflates the dimension and would
157
+ otherwise surface as hundreds of blank records. Note that openpyxl's
158
+ `reset_dimensions()` is not a fix here — it falls back to the sheet
159
+ maximum and yields over a million empty rows.
160
+
161
+ Args:
162
+ resource: The resource descriptor.
163
+
164
+ Returns:
165
+ A list of rows as lists of strings.
166
+ """
167
+ sub_namespace = resource.metadata["sub_namespace"]
168
+ header_row = resolve_header_row(resource)
169
+ logger.debug(f"Reading raw rows from Excel sheet: {sub_namespace}")
170
+ cache_key = f"data:{resource.path}:{sub_namespace}:{header_row}"
171
+ cached = get_cache(cache_key, str(resource.path))
172
+ if cached:
173
+ logger.debug(f"Cache hit for Excel data: {resource.path}")
174
+ return cached
175
+
176
+ logger.debug(f"Cache miss, reading from Excel file: {resource.path}")
177
+ width = len(resource.metadata.get("header") or [])
178
+ rows: list[list[str]] = []
179
+ skipped = 0
180
+ with self._open_pair(resource.path) as (values, formulas):
181
+ merged = _merged_rows(values[sub_namespace], formulas[sub_namespace])
182
+ for row_number, cells in merged:
183
+ if row_number <= header_row:
184
+ continue
185
+ if all(_is_blank(cell) for cell in cells):
186
+ skipped += 1
187
+ continue
188
+ row = ["" if cell is None else str(cell) for cell in cells]
189
+ if width:
190
+ row = (row + [""] * width)[:width]
191
+ rows.append(row)
192
+
193
+ if skipped:
194
+ logger.debug(
195
+ "Skipped %d empty rows in %s [%s]", skipped, resource.path, sub_namespace
196
+ )
197
+ set_cache(cache_key, rows, ttl_seconds=300, resource=str(resource.path)) # 5 min TTL
198
+ return rows
199
+
200
+ def _write_rows(self, resource: ResourceDescriptor, rows: list[dict[str, Any]], header: list[str]) -> None:
201
+ """Write rows to the Excel worksheet.
202
+
203
+ Args:
204
+ resource: The resource descriptor.
205
+ rows: The rows to write.
206
+ header: The column headers.
207
+ """
208
+ sub_namespace = resource.metadata["sub_namespace"]
209
+ header_row = resolve_header_row(resource)
210
+ first_data_row = header_row + 1
211
+ logger.info(f"Writing rows to Excel worksheet: {sub_namespace}")
212
+ workbook = load_workbook(resource.path)
213
+ try:
214
+ sheet = workbook[sub_namespace]
215
+ # Clear existing data, leaving the header and anything above it intact.
216
+ for row in sheet.iter_rows(min_row=first_data_row):
217
+ for cell in row:
218
+ cell.value = None
219
+ # Write new data
220
+ for row_idx, row_data in enumerate(rows, start=first_data_row):
221
+ for col_idx, col_name in enumerate(header):
222
+ sheet.cell(row=row_idx, column=col_idx+1).value = row_data.get(col_name, "")
223
+
224
+ def write_tmp(tmp_path: Path) -> None:
225
+ workbook.save(tmp_path)
226
+
227
+ atomic_write(resource.path, ".xlsx", write_tmp)
228
+ finally:
229
+ workbook.close()
230
+
231
+ # Invalidate cache after mutation
232
+ invalidate_cache(str(resource.path))
233
+ logger.debug(f"Successfully wrote {len(rows)} rows to Excel file: {resource.path}")
234
+
235
+ def _set_header_metadata(
236
+ self, descriptor: ResourceDescriptor, value_sheet: Any, formula_sheet: Any, header_row: int
237
+ ) -> None:
238
+ """Parse the header and sample row for a sheet into descriptor metadata."""
239
+ from .dataset_plugin import _ensure_header
240
+
241
+ header, sample = _extract_header_and_sample(value_sheet, formula_sheet, header_row)
242
+ descriptor.metadata["header"] = _ensure_header(header)
243
+ descriptor.metadata["sample_row"] = [
244
+ str(cell) if cell is not None else None for cell in sample
245
+ ]
246
+ descriptor.metadata["header_row"] = header_row
247
+
248
+ @staticmethod
249
+ def _open_pair(path: Path) -> "_WorkbookPair":
250
+ """Open the workbook twice: once for cached values, once for formulas.
251
+
252
+ Two passes are the only way to tell "empty cell" from "formula whose
253
+ result was never cached" — openpyxl surfaces one or the other, never both.
254
+ Both are read-only and streamed, and the result is cached for 5 minutes,
255
+ so the second parse is paid rarely.
256
+ """
257
+ return _WorkbookPair(path)
258
+
259
+
260
+ class _WorkbookPair:
261
+ """Context manager yielding (values_workbook, formulas_workbook)."""
262
+
263
+ def __init__(self, path: Path) -> None:
264
+ self._path = path
265
+
266
+ def __enter__(self) -> tuple[Any, Any]:
267
+ self._values = load_workbook(self._path, read_only=True, data_only=True)
268
+ try:
269
+ self._formulas = load_workbook(self._path, read_only=True, data_only=False)
270
+ except Exception:
271
+ self._values.close()
272
+ raise
273
+ return self._values, self._formulas
274
+
275
+ def __exit__(self, *exc_info: Any) -> None:
276
+ try:
277
+ self._formulas.close()
278
+ finally:
279
+ self._values.close()
@@ -53,8 +53,8 @@
53
53
  <form id="createForm">
54
54
  {% for col_name, col_info in schema.columns.items() %}
55
55
  <div class="mb-3">
56
- <label for="create_{{ col_name }}" class="form-label">{{ col_name }}</label>
57
- <input type="text" class="form-control" id="create_{{ col_name }}" name="{{ col_name }}">
56
+ <label for="create_col_{{ loop.index0 }}" class="form-label">{{ col_name }}</label>
57
+ <input type="text" class="form-control" id="create_col_{{ loop.index0 }}" name="{{ col_name }}">
58
58
  </div>
59
59
  {% endfor %}
60
60
  </form>
@@ -80,8 +80,8 @@
80
80
  <input type="hidden" id="edit_row_id" name="_row_id">
81
81
  {% for col_name, col_info in schema.columns.items() %}
82
82
  <div class="mb-3">
83
- <label for="edit_{{ col_name }}" class="form-label">{{ col_name }}</label>
84
- <input type="text" class="form-control" id="edit_{{ col_name }}" name="{{ col_name }}">
83
+ <label for="edit_col_{{ loop.index0 }}" class="form-label">{{ col_name }}</label>
84
+ <input type="text" class="form-control" id="edit_col_{{ loop.index0 }}" name="{{ col_name }}">
85
85
  </div>
86
86
  {% endfor %}
87
87
  </form>
@@ -102,7 +102,16 @@
102
102
 
103
103
  <script>
104
104
  let dataTable;
105
- const apiUrl = '{{ api_url }}';
105
+ const apiUrl = {{ api_url | tojson }};
106
+
107
+ // Column names are data, not code. They come from a spreadsheet header or a
108
+ // hand-written schema, so they may contain spaces, quotes, parentheses, dots
109
+ // or a leading digit. Interpolating them into JS expressions turns a column
110
+ // named "Total (net)" into a syntax error that takes down this whole script
111
+ // -- including the DataTable init below -- and the table renders as a bare
112
+ // header with no rows and nothing in the console to explain it. Everything
113
+ // below indexes into this array instead.
114
+ const COLUMNS = {{ schema.columns | list | tojson }};
106
115
 
107
116
  function csrfHeaders(headers = {}) {
108
117
  const token = window.getAdaptCsrfToken ? window.getAdaptCsrfToken() : '';
@@ -124,26 +133,40 @@
124
133
  alert('Error loading data: ' + error);
125
134
  }
126
135
  },
127
- columns: [
128
- {% for col_name, col_info in schema.columns.items() %}
129
- { data: '{{ col_name }}' },
130
- {% endfor %}
131
- {% if not readonly %}
132
- {
136
+ columns: buildColumns()
137
+ });
138
+ });
139
+
140
+ function buildColumns() {
141
+ // An accessor function rather than `data: name`: DataTables parses a
142
+ // string source as object notation, so a column called "Q1.Revenue"
143
+ // would be looked up as row.Q1.Revenue and quietly resolve to nothing.
144
+ const columns = COLUMNS.map(function (name) {
145
+ return {
146
+ data: function (row) { return row[name]; },
147
+ defaultContent: '',
148
+ render: $.fn.dataTable.render.text()
149
+ };
150
+ });
151
+
152
+ {% if not readonly %}
153
+ // Guarded by Jinja rather than at runtime so a read-only page never ships
154
+ // write controls, not even as unreachable markup.
155
+ columns.push({
133
156
  data: null,
134
157
  orderable: false,
135
158
  searchable: false,
136
159
  render: function (data, type, row) {
137
160
  return `
138
- <button class="btn btn-sm btn-warning me-1" onclick="editRecord(${row._row_id})">Edit</button>
139
- <button class="btn btn-sm btn-danger" onclick="deleteRecord(${row._row_id})">Delete</button>
140
- `;
161
+ <button class="btn btn-sm btn-warning me-1" onclick="editRecord(${row._row_id})">Edit</button>
162
+ <button class="btn btn-sm btn-danger" onclick="deleteRecord(${row._row_id})">Delete</button>
163
+ `;
141
164
  }
142
- }
143
- {% endif %}
144
- ]
145
- });
146
165
  });
166
+ {% endif %}
167
+
168
+ return columns;
169
+ }
147
170
 
148
171
  {% if not readonly %}
149
172
  function createRecord() {
@@ -180,9 +203,12 @@
180
203
  const rowData = dataTable.rows().data().toArray().find(row => row._row_id === rowId);
181
204
  if (rowData) {
182
205
  document.getElementById('edit_row_id').value = rowId;
183
- {% for col_name, col_info in schema.columns.items() %}
184
- document.getElementById('edit_{{ col_name }}').value = rowData.{{ col_name }};
185
- {% endfor %}
206
+ COLUMNS.forEach(function (name, index) {
207
+ const input = document.getElementById('edit_col_' + index);
208
+ if (input) {
209
+ input.value = rowData[name] ?? '';
210
+ }
211
+ });
186
212
  const modal = bootstrap.Modal.getOrCreateInstance(document.getElementById('editModal'));
187
213
  modal.show();
188
214
  }