footprinter-cli 1.0.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. footprinter/__init__.py +8 -0
  2. footprinter/access.py +431 -0
  3. footprinter/api/__init__.py +1 -0
  4. footprinter/api/db.py +61 -0
  5. footprinter/api/entities.py +250 -0
  6. footprinter/api/search.py +47 -0
  7. footprinter/api/semantic.py +33 -0
  8. footprinter/api/server.py +66 -0
  9. footprinter/api/status.py +15 -0
  10. footprinter/bundled/__init__.py +0 -0
  11. footprinter/bundled/config.example.yaml +161 -0
  12. footprinter/bundled/patterns/context_patterns.yaml +18 -0
  13. footprinter/bundled/patterns/extensions.yaml +283 -0
  14. footprinter/bundled/patterns/filename_patterns.yaml +61 -0
  15. footprinter/bundled/patterns/mime_mappings.yaml +68 -0
  16. footprinter/bundled/patterns/salesforce_rules.yaml +84 -0
  17. footprinter/bundled/patterns/security_patterns.yaml +27 -0
  18. footprinter/bundled/samples/hidden-client-file-sample.txt +2 -0
  19. footprinter/bundled/samples/opaque-project-file-sample.txt +2 -0
  20. footprinter/bundled/samples/visible-file-sample.txt +2 -0
  21. footprinter/cli/__init__.py +135 -0
  22. footprinter/cli/__main__.py +6 -0
  23. footprinter/cli/_common.py +327 -0
  24. footprinter/cli/_policy_helpers.py +646 -0
  25. footprinter/cli/_prompt.py +220 -0
  26. footprinter/cli/_sample_seed.py +204 -0
  27. footprinter/cli/api_cmd.py +32 -0
  28. footprinter/cli/connect.py +591 -0
  29. footprinter/cli/data.py +879 -0
  30. footprinter/cli/delete.py +128 -0
  31. footprinter/cli/ingest.py +543 -0
  32. footprinter/cli/mcp_cmd.py +750 -0
  33. footprinter/cli/mcp_setup.py +306 -0
  34. footprinter/cli/search.py +393 -0
  35. footprinter/cli/search_cmd.py +69 -0
  36. footprinter/cli/setup.py +2001 -0
  37. footprinter/cli/status.py +747 -0
  38. footprinter/cli/status_cmd.py +104 -0
  39. footprinter/cli/upsert.py +794 -0
  40. footprinter/cli/vectorize_cmd.py +215 -0
  41. footprinter/cli/view.py +322 -0
  42. footprinter/connectors/__init__.py +171 -0
  43. footprinter/connectors/config_utils.py +141 -0
  44. footprinter/db/__init__.py +37 -0
  45. footprinter/db/browser.py +198 -0
  46. footprinter/db/chats.py +602 -0
  47. footprinter/db/clients.py +307 -0
  48. footprinter/db/emails.py +279 -0
  49. footprinter/db/files.py +724 -0
  50. footprinter/db/folders.py +659 -0
  51. footprinter/db/messages.py +192 -0
  52. footprinter/db/policies.py +151 -0
  53. footprinter/db/projects.py +673 -0
  54. footprinter/db/search.py +573 -0
  55. footprinter/db/sql_utils.py +168 -0
  56. footprinter/db/status.py +320 -0
  57. footprinter/db/uploads.py +70 -0
  58. footprinter/ingest/__init__.py +0 -0
  59. footprinter/ingest/adapters/__init__.py +33 -0
  60. footprinter/ingest/adapters/browser.py +54 -0
  61. footprinter/ingest/adapters/chat.py +57 -0
  62. footprinter/ingest/adapters/ingest.py +146 -0
  63. footprinter/ingest/adapters/local_files.py +68 -0
  64. footprinter/ingest/adapters/local_folders.py +52 -0
  65. footprinter/ingest/adapters/protocol.py +174 -0
  66. footprinter/ingest/browser_indexer.py +216 -0
  67. footprinter/ingest/chat_dedup.py +156 -0
  68. footprinter/ingest/chat_indexer.py +487 -0
  69. footprinter/ingest/chat_parsers/__init__.py +8 -0
  70. footprinter/ingest/chat_parsers/chatgpt_parser.py +229 -0
  71. footprinter/ingest/chat_parsers/claude_parser.py +161 -0
  72. footprinter/ingest/cli.py +827 -0
  73. footprinter/ingest/content_extractors.py +117 -0
  74. footprinter/ingest/database.py +36 -0
  75. footprinter/ingest/db/__init__.py +1 -0
  76. footprinter/ingest/db/connector_schema.py +47 -0
  77. footprinter/ingest/db/migration.py +315 -0
  78. footprinter/ingest/db/schema.py +1043 -0
  79. footprinter/ingest/db/security.py +6 -0
  80. footprinter/ingest/file_indexer.py +223 -0
  81. footprinter/ingest/file_scanner.py +277 -0
  82. footprinter/ingest/folder_indexer.py +226 -0
  83. footprinter/ingest/full_content_extractor.py +321 -0
  84. footprinter/ingest/orchestrator.py +112 -0
  85. footprinter/ingest/pipe_runner.py +200 -0
  86. footprinter/ingest/processing.py +165 -0
  87. footprinter/ingest/registry.py +186 -0
  88. footprinter/ingest/run_record.py +91 -0
  89. footprinter/ingest/status.py +346 -0
  90. footprinter/mcp/__init__.py +0 -0
  91. footprinter/mcp/__main__.py +5 -0
  92. footprinter/mcp/db.py +67 -0
  93. footprinter/mcp/errors.py +105 -0
  94. footprinter/mcp/extraction.py +226 -0
  95. footprinter/mcp/server.py +39 -0
  96. footprinter/mcp/tools/__init__.py +0 -0
  97. footprinter/mcp/tools/navigation.py +70 -0
  98. footprinter/mcp/tools/read.py +75 -0
  99. footprinter/mcp/tools/search.py +158 -0
  100. footprinter/mcp/tools/semantic.py +79 -0
  101. footprinter/mcp/tools/status.py +19 -0
  102. footprinter/paths.py +117 -0
  103. footprinter/permissions.py +1152 -0
  104. footprinter/semantic/__init__.py +13 -0
  105. footprinter/semantic/chunking.py +52 -0
  106. footprinter/semantic/embeddings.py +23 -0
  107. footprinter/semantic/hybrid_search.py +273 -0
  108. footprinter/semantic/vector_store.py +471 -0
  109. footprinter/services/__init__.py +49 -0
  110. footprinter/services/access_service.py +342 -0
  111. footprinter/services/chat_service.py +85 -0
  112. footprinter/services/client_service.py +267 -0
  113. footprinter/services/content_service.py +181 -0
  114. footprinter/services/email_service.py +89 -0
  115. footprinter/services/file_service.py +83 -0
  116. footprinter/services/folder_service.py +122 -0
  117. footprinter/services/includes.py +19 -0
  118. footprinter/services/ingest_service.py +231 -0
  119. footprinter/services/project_service.py +262 -0
  120. footprinter/services/roles.py +25 -0
  121. footprinter/services/search_service.py +177 -0
  122. footprinter/services/semantic_service.py +360 -0
  123. footprinter/services/status_service.py +18 -0
  124. footprinter/services/visit_service.py +65 -0
  125. footprinter/source_registry.py +194 -0
  126. footprinter/utils/__init__.py +7 -0
  127. footprinter/utils/hash_utils.py +59 -0
  128. footprinter/utils/logging_config.py +68 -0
  129. footprinter/utils/mime.py +30 -0
  130. footprinter/utils/text.py +6 -0
  131. footprinter/utils/time.py +11 -0
  132. footprinter/visibility.py +1264 -0
  133. footprinter_cli-1.0.0rc1.dist-info/LICENSE +21 -0
  134. footprinter_cli-1.0.0rc1.dist-info/METADATA +223 -0
  135. footprinter_cli-1.0.0rc1.dist-info/RECORD +138 -0
  136. footprinter_cli-1.0.0rc1.dist-info/WHEEL +5 -0
  137. footprinter_cli-1.0.0rc1.dist-info/entry_points.txt +2 -0
  138. footprinter_cli-1.0.0rc1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,181 @@
1
+ """content_service — file content I/O (disk reads, remote reads, text extraction).
2
+
3
+ Extracted from the former ``read_service`` module. This module owns all
4
+ filesystem and remote storage I/O. Access gating lives in ``access_service``.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ import sqlite3
10
+ from pathlib import Path
11
+ from typing import Literal, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Public API
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ def read_file(
22
+ conn: sqlite3.Connection,
23
+ metadata: dict,
24
+ *,
25
+ format: Literal["text", "raw"] = "text",
26
+ ) -> dict:
27
+ """Read file content from disk or Drive.
28
+
29
+ Requires ``metadata`` from a prior ``gate_access()`` call (status ``ok``).
30
+ Returns dict with ``status`` (``ok`` or error), ``content``, ``metadata``.
31
+ """
32
+ from footprinter.mcp.extraction import extract_text, get_extractor_for_file
33
+ from footprinter.source_registry import SourceRegistry
34
+
35
+ meta = dict(metadata)
36
+ source = meta.get("source", "")
37
+ name = meta.get("name", "")
38
+ mime_type = meta.get("mime_type", "")
39
+
40
+ # Get raw bytes
41
+ registry = SourceRegistry(conn)
42
+ data: Optional[bytes] = None
43
+
44
+ if source == "local":
45
+ data = _read_local_file_bytes(meta.get("path", ""))
46
+ elif registry.is_remote_source(source):
47
+ external_id = meta.get("external_id")
48
+ account = meta.get("account")
49
+ if external_id and account:
50
+ data = _read_remote_file_bytes(source, external_id, account, mime_type)
51
+ else:
52
+ return {
53
+ "status": "read_failed",
54
+ "metadata": meta,
55
+ "message": f"file:{meta.get('id')} missing external_id or account",
56
+ }
57
+ else:
58
+ return {
59
+ "status": "read_failed",
60
+ "metadata": meta,
61
+ "message": f"file:{meta.get('id')} unknown source={source}",
62
+ }
63
+
64
+ if data is None:
65
+ return {
66
+ "status": "read_failed",
67
+ "metadata": meta,
68
+ "message": f"file:{meta.get('id')} null data from {source}",
69
+ }
70
+
71
+ # Determine if extraction is needed
72
+ extractor_type = get_extractor_for_file(name, mime_type)
73
+
74
+ if format == "raw" or extractor_type is None:
75
+ content = _decode_bytes(data)
76
+ if content is None:
77
+ return {
78
+ "status": "decode_failed",
79
+ "metadata": meta,
80
+ "message": f"file:{meta.get('id')} decode failed (raw mode)",
81
+ }
82
+ meta["extraction_method"] = "raw"
83
+ meta["extraction_success"] = True
84
+ meta["extraction_error"] = None
85
+ return {"status": "ok", "content": content, "metadata": meta}
86
+
87
+ # Text mode with extraction
88
+ extracted_text, error = extract_text(data, extractor_type)
89
+
90
+ if extracted_text is not None:
91
+ meta["extraction_method"] = extractor_type
92
+ meta["extraction_success"] = True
93
+ meta["extraction_error"] = None
94
+ return {"status": "ok", "content": extracted_text, "metadata": meta}
95
+
96
+ # Extraction failed — fall back to raw decode
97
+ logger.warning(f"Extraction failed for {name}: {error}, falling back to raw")
98
+ content = _decode_bytes(data)
99
+ if content is None:
100
+ meta["extraction_method"] = extractor_type
101
+ meta["extraction_success"] = False
102
+ meta["extraction_error"] = error
103
+ return {
104
+ "status": "extraction_failed",
105
+ "metadata": meta,
106
+ "message": f"file:{meta.get('id')} extraction+decode failed: {error}",
107
+ }
108
+
109
+ meta["extraction_method"] = "raw"
110
+ meta["extraction_success"] = False
111
+ meta["extraction_error"] = error
112
+ return {"status": "ok", "content": content, "metadata": meta}
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # File I/O helpers
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def _validate_local_path(path: str) -> Path:
121
+ """Validate that a local path falls within the home directory.
122
+
123
+ Defense-in-depth: prevents a corrupted database from being used
124
+ to read arbitrary files via the MCP server.
125
+ """
126
+ p = Path(path).resolve()
127
+ home = Path.home().resolve()
128
+ if not str(p).startswith(str(home) + os.sep) and p != home:
129
+ raise PermissionError(f"Path outside home directory: {p}")
130
+ return p
131
+
132
+
133
+ def _read_local_file_bytes(path: str, max_bytes: int = 500_000) -> Optional[bytes]:
134
+ """Read a local file as raw bytes."""
135
+ try:
136
+ p = _validate_local_path(path)
137
+ if not p.exists():
138
+ return None
139
+ with open(p, "rb") as f:
140
+ data = f.read(max_bytes)
141
+ return data
142
+ except PermissionError as e:
143
+ logger.error(f"Path containment violation for {path}: {e}")
144
+ return None
145
+ except Exception as e:
146
+ logger.error(f"Error reading local file bytes {path}: {e}")
147
+ return None
148
+
149
+
150
+ def _read_remote_file_bytes(
151
+ source: str,
152
+ external_id: str,
153
+ account: str,
154
+ mime_type: str = "",
155
+ ) -> Optional[bytes]:
156
+ """Download raw bytes from a remote source via connector hooks."""
157
+ from footprinter.connectors import discover_connectors, is_installed, resolve_hook
158
+
159
+ for name, spec in discover_connectors().items():
160
+ if not is_installed(spec) or not spec.read_file:
161
+ continue
162
+ try:
163
+ fn = resolve_hook(spec.read_file)
164
+ if fn:
165
+ return fn(external_id, account, mime_type)
166
+ except Exception:
167
+ logger.warning("read_file hook failed for connector %s", name, exc_info=True)
168
+
169
+ logger.error("No connector provides read_file for source=%s", source)
170
+ return None
171
+
172
+
173
+ def _decode_bytes(data: bytes) -> Optional[str]:
174
+ """Decode bytes to string, trying UTF-8 then Latin-1."""
175
+ try:
176
+ return data.decode("utf-8")
177
+ except UnicodeDecodeError:
178
+ try:
179
+ return data.decode("latin-1")
180
+ except UnicodeDecodeError:
181
+ return None
@@ -0,0 +1,89 @@
1
+ """Email read service — get/list with role-based visibility filtering."""
2
+
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ from footprinter.db import emails as db
7
+ from footprinter.services.access_service import (
8
+ filter_result,
9
+ filter_results_list,
10
+ strip_content_for_denied,
11
+ )
12
+ from footprinter.services.roles import Role
13
+
14
+
15
+ def get(conn: sqlite3.Connection, email_id: int, *, role: Role = Role.ADMIN) -> dict | None:
16
+ """Fetch a single email by ID, filtered by role."""
17
+ result = db.get_email(conn, email_id)
18
+ if result is None:
19
+ return None
20
+ if role.sees_all:
21
+ return result
22
+ return filter_result("email", result)
23
+
24
+
25
+ def assign(
26
+ conn: sqlite3.Connection,
27
+ email_id: int,
28
+ *,
29
+ role: Role = Role.ADMIN,
30
+ project_id: int | None = None,
31
+ client_id: int | None = None,
32
+ ) -> dict | None:
33
+ """Assign an email to a project and/or client.
34
+
35
+ Returns result dict on success, None if not found.
36
+ Raises PermissionError if role cannot write.
37
+ """
38
+ if not role.can_write:
39
+ raise PermissionError("Role does not permit write operations")
40
+ result = db.update_email_relationships(
41
+ conn,
42
+ email_id,
43
+ project_id=project_id,
44
+ client_id=client_id,
45
+ )
46
+ if result is None:
47
+ return None
48
+ resp: dict = {"id": email_id}
49
+ if project_id is not None:
50
+ resp["project_id"] = project_id
51
+ if client_id is not None:
52
+ resp["client_id"] = client_id
53
+ return resp
54
+
55
+
56
+ def list_(
57
+ conn: sqlite3.Connection,
58
+ *,
59
+ role: Role = Role.ADMIN,
60
+ account: Optional[str] = None,
61
+ client_id: Optional[int] = None,
62
+ project_id: Optional[int] = None,
63
+ query: Optional[str] = None,
64
+ has_attachments: Optional[bool] = None,
65
+ sort_by: str = "received_at",
66
+ order: str = "desc",
67
+ limit: int = 50,
68
+ page: int = 1,
69
+ ) -> dict:
70
+ """List emails with pagination, filtered by role."""
71
+ response = db.list_emails(
72
+ conn,
73
+ account=account,
74
+ client_id=client_id,
75
+ project_id=project_id,
76
+ query=query,
77
+ has_attachments=has_attachments,
78
+ sort_by=sort_by,
79
+ order=order,
80
+ limit=limit,
81
+ page=page,
82
+ )
83
+ if role.sees_all:
84
+ return response
85
+ filtered, suppressed = filter_results_list("email", response["emails"])
86
+ filtered = strip_content_for_denied("email", filtered)
87
+ response["emails"] = filtered
88
+ response["suppressed"] = suppressed
89
+ return response
@@ -0,0 +1,83 @@
1
+ """File read service — get/list with role-based visibility filtering."""
2
+
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ from footprinter.db import files as db
7
+ from footprinter.services.access_service import (
8
+ filter_result,
9
+ filter_results_list,
10
+ strip_content_for_denied,
11
+ )
12
+ from footprinter.services.roles import Role
13
+
14
+
15
+ def get(conn: sqlite3.Connection, file_id: int, *, role: Role = Role.ADMIN) -> dict | None:
16
+ """Fetch a single file by ID, filtered by role."""
17
+ result = db.get_file(conn, file_id)
18
+ if result is None:
19
+ return None
20
+ if role.sees_all:
21
+ return result
22
+ return filter_result("file", result)
23
+
24
+
25
+ def assign(
26
+ conn: sqlite3.Connection,
27
+ file_id: int,
28
+ *,
29
+ role: Role = Role.ADMIN,
30
+ project_id: int | None = None,
31
+ client_id: int | None = None,
32
+ ) -> dict | None:
33
+ """Assign a file to a project and/or client.
34
+
35
+ Returns result dict on success, None if not found.
36
+ Raises PermissionError if role cannot write.
37
+ """
38
+ if not role.can_write:
39
+ raise PermissionError("Role does not permit write operations")
40
+ result = db.update_file_relationships(
41
+ conn,
42
+ file_id,
43
+ project_id=project_id,
44
+ client_id=client_id,
45
+ )
46
+ if result is None:
47
+ return None
48
+ resp: dict = {"id": file_id}
49
+ if project_id is not None:
50
+ resp["project_id"] = project_id
51
+ if client_id is not None:
52
+ resp["client_id"] = client_id
53
+ return resp
54
+
55
+
56
+ def list_(
57
+ conn: sqlite3.Connection,
58
+ *,
59
+ role: Role = Role.ADMIN,
60
+ project_id: Optional[int] = None,
61
+ source: Optional[list[str]] = None,
62
+ status: Optional[str | list[str]] = None,
63
+ content_type: Optional[str] = None,
64
+ limit: int = 50,
65
+ page: int = 1,
66
+ ) -> dict:
67
+ """List files with pagination, filtered by role."""
68
+ response = db.list_files(
69
+ conn,
70
+ project_id=project_id,
71
+ source=source,
72
+ status=status,
73
+ content_type=content_type,
74
+ limit=limit,
75
+ page=page,
76
+ )
77
+ if role.sees_all:
78
+ return response
79
+ filtered, suppressed = filter_results_list("file", response["files"])
80
+ filtered = strip_content_for_denied("file", filtered)
81
+ response["files"] = filtered
82
+ response["suppressed"] = suppressed
83
+ return response
@@ -0,0 +1,122 @@
1
+ """Folder read service — get/list with role-based visibility filtering."""
2
+
3
+ import sqlite3
4
+ from typing import Optional
5
+
6
+ from footprinter.db import folders as db
7
+ from footprinter.services.access_service import (
8
+ _read_visibility,
9
+ filter_result,
10
+ filter_results_list,
11
+ )
12
+ from footprinter.services.roles import Role
13
+
14
+
15
+ def get(conn: sqlite3.Connection, folder_id: int, *, role: Role = Role.ADMIN) -> dict | None:
16
+ """Fetch a single folder by ID, filtered by role."""
17
+ result = db.get_folder(conn, folder_id)
18
+ if result is None:
19
+ return None
20
+ if role.sees_all:
21
+ return result
22
+ return filter_result("folder", result)
23
+
24
+
25
+ def assign(
26
+ conn: sqlite3.Connection,
27
+ folder_id: int,
28
+ *,
29
+ role: Role = Role.ADMIN,
30
+ project_id: int | None = None,
31
+ client_id: int | None = None,
32
+ ) -> dict | None:
33
+ """Assign a folder to a project and/or client.
34
+
35
+ Returns result dict on success, None if not found.
36
+ Raises PermissionError if role cannot write, ValueError if project doesn't exist.
37
+ """
38
+ if not role.can_write:
39
+ raise PermissionError("Role does not permit write operations")
40
+ result = db.update_folder_relationships(
41
+ conn,
42
+ folder_id,
43
+ project_id=project_id,
44
+ client_id=client_id,
45
+ )
46
+ if result is None:
47
+ return None
48
+ resp: dict = {"id": folder_id}
49
+ if project_id is not None:
50
+ resp["project_id"] = project_id
51
+ if client_id is not None:
52
+ resp["client_id"] = client_id
53
+ return resp
54
+
55
+
56
+ def list_(
57
+ conn: sqlite3.Connection,
58
+ *,
59
+ role: Role = Role.ADMIN,
60
+ project_id: Optional[int] = None,
61
+ depth: Optional[int] = 1,
62
+ include_hidden: bool = False,
63
+ sort_by: str = "size",
64
+ limit: int = 50,
65
+ page: int = 1,
66
+ ) -> dict:
67
+ """List folders with pagination, filtered by role."""
68
+ response = db.list_folders(
69
+ conn,
70
+ project_id=project_id,
71
+ depth=depth,
72
+ include_hidden=include_hidden,
73
+ sort_by=sort_by,
74
+ limit=limit,
75
+ page=page,
76
+ )
77
+ if role.sees_all:
78
+ return response
79
+ filtered, suppressed = filter_results_list("folder", response["folders"])
80
+ response["folders"] = filtered
81
+ response["suppressed"] = suppressed
82
+ return response
83
+
84
+
85
+ def get_by_path(
86
+ conn: sqlite3.Connection,
87
+ path: str,
88
+ *,
89
+ role: Role = Role.ADMIN,
90
+ ) -> dict | None:
91
+ """Look up a folder by exact path with navigation data, filtered by role.
92
+
93
+ Returns None if folder doesn't exist or is hidden (for VIEWER).
94
+ Returns opaque dict (no children) for opaque folders (for VIEWER).
95
+ Returns full navigation dict for visible folders.
96
+ """
97
+ row = db.get_folder_by_path(conn, path)
98
+ if row is None:
99
+ return None
100
+
101
+ visibility = _read_visibility(row)
102
+
103
+ if not role.sees_all:
104
+ if visibility == "hidden":
105
+ return None
106
+ if visibility == "opaque":
107
+ return filter_result("folder", row)
108
+
109
+ # Fetch navigation data (files, subfolders, recursive count)
110
+ nav = db.get_folder_navigation(conn, row["id"], path)
111
+ result = {**row, **nav}
112
+
113
+ if role.sees_all:
114
+ return result
115
+
116
+ # Filter children by visibility
117
+ result["files"], file_sup = filter_results_list("file", result["files"])
118
+ result["subfolders"], sub_sup = filter_results_list("folder", result["subfolders"])
119
+ total_sup = file_sup + sub_sup
120
+ if total_sup:
121
+ result["suppressed"] = total_sup
122
+ return result
@@ -0,0 +1,19 @@
1
+ """Include parameter validation for the service layer."""
2
+
3
+
4
+ def validate_include(
5
+ include: list[str] | None,
6
+ valid: frozenset[str],
7
+ ) -> frozenset[str]:
8
+ """Validate and normalize an include parameter.
9
+
10
+ Returns a frozenset of validated include names.
11
+ Raises ValueError for invalid values.
12
+ """
13
+ if include is None:
14
+ return frozenset()
15
+ result = frozenset(include)
16
+ invalid = result - valid
17
+ if invalid:
18
+ raise ValueError(f"Invalid include values: {', '.join(sorted(invalid))}. Valid: {', '.join(sorted(valid))}")
19
+ return result