squirrels 0.5.0b3__py3-none-any.whl → 0.6.0.post0__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 (93) hide show
  1. squirrels/__init__.py +4 -0
  2. squirrels/_api_routes/__init__.py +5 -0
  3. squirrels/_api_routes/auth.py +337 -0
  4. squirrels/_api_routes/base.py +196 -0
  5. squirrels/_api_routes/dashboards.py +156 -0
  6. squirrels/_api_routes/data_management.py +148 -0
  7. squirrels/_api_routes/datasets.py +220 -0
  8. squirrels/_api_routes/project.py +289 -0
  9. squirrels/_api_server.py +440 -792
  10. squirrels/_arguments/__init__.py +0 -0
  11. squirrels/_arguments/{_init_time_args.py → init_time_args.py} +23 -43
  12. squirrels/_arguments/{_run_time_args.py → run_time_args.py} +32 -68
  13. squirrels/_auth.py +590 -264
  14. squirrels/_command_line.py +130 -58
  15. squirrels/_compile_prompts.py +147 -0
  16. squirrels/_connection_set.py +16 -15
  17. squirrels/_constants.py +36 -11
  18. squirrels/_dashboards.py +179 -0
  19. squirrels/_data_sources.py +40 -34
  20. squirrels/_dataset_types.py +16 -11
  21. squirrels/_env_vars.py +209 -0
  22. squirrels/_exceptions.py +9 -37
  23. squirrels/_http_error_responses.py +52 -0
  24. squirrels/_initializer.py +7 -6
  25. squirrels/_logging.py +121 -0
  26. squirrels/_manifest.py +155 -77
  27. squirrels/_mcp_server.py +578 -0
  28. squirrels/_model_builder.py +11 -55
  29. squirrels/_model_configs.py +5 -5
  30. squirrels/_model_queries.py +1 -1
  31. squirrels/_models.py +276 -143
  32. squirrels/_package_data/base_project/.env +1 -24
  33. squirrels/_package_data/base_project/.env.example +31 -17
  34. squirrels/_package_data/base_project/connections.yml +4 -3
  35. squirrels/_package_data/base_project/dashboards/dashboard_example.py +13 -7
  36. squirrels/_package_data/base_project/dashboards/dashboard_example.yml +6 -6
  37. squirrels/_package_data/base_project/docker/Dockerfile +2 -2
  38. squirrels/_package_data/base_project/docker/compose.yml +1 -1
  39. squirrels/_package_data/base_project/duckdb_init.sql +1 -0
  40. squirrels/_package_data/base_project/models/builds/build_example.py +2 -2
  41. squirrels/_package_data/base_project/models/dbviews/dbview_example.sql +7 -2
  42. squirrels/_package_data/base_project/models/dbviews/dbview_example.yml +16 -10
  43. squirrels/_package_data/base_project/models/federates/federate_example.py +27 -17
  44. squirrels/_package_data/base_project/models/federates/federate_example.sql +3 -7
  45. squirrels/_package_data/base_project/models/federates/federate_example.yml +7 -7
  46. squirrels/_package_data/base_project/models/sources.yml +5 -6
  47. squirrels/_package_data/base_project/parameters.yml +24 -38
  48. squirrels/_package_data/base_project/pyconfigs/connections.py +8 -3
  49. squirrels/_package_data/base_project/pyconfigs/context.py +26 -14
  50. squirrels/_package_data/base_project/pyconfigs/parameters.py +124 -81
  51. squirrels/_package_data/base_project/pyconfigs/user.py +48 -15
  52. squirrels/_package_data/base_project/resources/public/.gitkeep +0 -0
  53. squirrels/_package_data/base_project/seeds/seed_categories.yml +1 -1
  54. squirrels/_package_data/base_project/seeds/seed_subcategories.yml +1 -1
  55. squirrels/_package_data/base_project/squirrels.yml.j2 +21 -31
  56. squirrels/_package_data/templates/login_successful.html +53 -0
  57. squirrels/_package_data/templates/squirrels_studio.html +22 -0
  58. squirrels/_parameter_configs.py +43 -22
  59. squirrels/_parameter_options.py +1 -1
  60. squirrels/_parameter_sets.py +41 -30
  61. squirrels/_parameters.py +560 -123
  62. squirrels/_project.py +487 -277
  63. squirrels/_py_module.py +71 -10
  64. squirrels/_request_context.py +33 -0
  65. squirrels/_schemas/__init__.py +0 -0
  66. squirrels/_schemas/auth_models.py +83 -0
  67. squirrels/_schemas/query_param_models.py +70 -0
  68. squirrels/_schemas/request_models.py +26 -0
  69. squirrels/_schemas/response_models.py +286 -0
  70. squirrels/_seeds.py +52 -13
  71. squirrels/_sources.py +29 -23
  72. squirrels/_utils.py +221 -42
  73. squirrels/_version.py +1 -3
  74. squirrels/arguments.py +7 -2
  75. squirrels/auth.py +4 -0
  76. squirrels/connections.py +2 -0
  77. squirrels/dashboards.py +3 -1
  78. squirrels/data_sources.py +6 -0
  79. squirrels/parameter_options.py +5 -0
  80. squirrels/parameters.py +5 -0
  81. squirrels/types.py +10 -3
  82. squirrels-0.6.0.post0.dist-info/METADATA +148 -0
  83. squirrels-0.6.0.post0.dist-info/RECORD +101 -0
  84. {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/WHEEL +1 -1
  85. squirrels/_api_response_models.py +0 -190
  86. squirrels/_dashboard_types.py +0 -82
  87. squirrels/_dashboards_io.py +0 -79
  88. squirrels-0.5.0b3.dist-info/METADATA +0 -110
  89. squirrels-0.5.0b3.dist-info/RECORD +0 -80
  90. /squirrels/_package_data/base_project/{assets → resources}/expenses.db +0 -0
  91. /squirrels/_package_data/base_project/{assets → resources}/weather.db +0 -0
  92. {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/entry_points.txt +0 -0
  93. {squirrels-0.5.0b3.dist-info → squirrels-0.6.0.post0.dist-info}/licenses/LICENSE +0 -0
squirrels/_manifest.py CHANGED
@@ -1,25 +1,58 @@
1
1
  from functools import cached_property
2
- from typing import Any
2
+ from typing import Literal, Any
3
3
  from urllib.parse import urlparse
4
4
  from sqlalchemy import Engine, create_engine
5
5
  from typing_extensions import Self
6
6
  from enum import Enum
7
7
  from pydantic import BaseModel, Field, field_validator, model_validator, ValidationInfo, ValidationError
8
- import yaml, time
8
+ import yaml, time, re
9
9
 
10
10
  from . import _constants as c, _utils as u
11
11
 
12
12
 
13
- class ProjectVarsConfig(BaseModel, extra="allow"):
13
+ class _ConfigWithNameBaseModel(BaseModel):
14
14
  name: str
15
+
16
+ @field_validator("name")
17
+ @classmethod
18
+ def validate_name(cls, v: str) -> str:
19
+ if not re.fullmatch(r"[A-Za-z0-9_-]+", v):
20
+ raise ValueError("Name must only contain alphanumeric characters, underscores, and dashes.")
21
+ return v
22
+
23
+
24
+ class AuthType(Enum):
25
+ REQUIRED = "required"
26
+ OPTIONAL = "optional"
27
+ NOTSET = "notset"
28
+
29
+
30
+ class AuthStrategy(Enum):
31
+ MANAGED = "managed"
32
+ EXTERNAL = "external"
33
+
34
+
35
+ class ProjectVarsConfig(_ConfigWithNameBaseModel, extra="allow"):
36
+ major_version: int
15
37
  label: str = ""
16
38
  description: str = ""
17
- major_version: int
39
+ auth_type: AuthType = AuthType.NOTSET
40
+ auth_strategy: AuthStrategy = AuthStrategy.MANAGED
41
+
42
+ @model_validator(mode="after")
43
+ def set_auth_strategy_defaults(self) -> Self:
44
+ if self.auth_strategy == AuthStrategy.EXTERNAL and self.auth_type == AuthType.OPTIONAL:
45
+ raise ValueError("auth_type can not be optional when auth_strategy is external")
46
+
47
+ if self.auth_type == AuthType.NOTSET:
48
+ self.auth_type = AuthType.REQUIRED if self.auth_strategy == AuthStrategy.EXTERNAL else AuthType.OPTIONAL
49
+
50
+ return self
18
51
 
19
52
  @model_validator(mode="after")
20
53
  def finalize_label(self) -> Self:
21
54
  if self.label == "":
22
- self.label = self.name
55
+ self.label = u.to_title_case(self.name)
23
56
  return self
24
57
 
25
58
 
@@ -35,14 +68,11 @@ class PackageConfig(BaseModel):
35
68
  return self
36
69
 
37
70
 
38
- class _ConfigWithNameBaseModel(BaseModel):
39
- name: str
40
-
41
-
42
71
  class ConnectionTypeEnum(Enum):
43
72
  SQLALCHEMY = "sqlalchemy"
44
73
  CONNECTORX = "connectorx"
45
74
  ADBC = "adbc"
75
+ DUCKDB = "duckdb"
46
76
 
47
77
 
48
78
  class ConnectionProperties(BaseModel):
@@ -71,26 +101,32 @@ class ConnectionProperties(BaseModel):
71
101
 
72
102
  @cached_property
73
103
  def dialect(self) -> str:
104
+ default_dialect = None
74
105
  if self.type == ConnectionTypeEnum.SQLALCHEMY:
75
106
  dialect = self.engine.dialect.name
107
+ elif self.type == ConnectionTypeEnum.DUCKDB:
108
+ dialect = self.uri.split(':')[0]
109
+ default_dialect = 'duckdb'
76
110
  else:
77
111
  url = urlparse(self.uri)
78
112
  dialect = url.scheme
79
113
 
80
- processed_dialect = next((d for d in ['sqlite', 'postgres', 'mysql'] if dialect.lower().startswith(d)), None)
114
+ processed_dialect = next((d for d in ['sqlite', 'postgres', 'mysql', 'duckdb'] if dialect.lower().startswith(d)), default_dialect)
81
115
  dialect = processed_dialect if processed_dialect is not None else dialect
82
116
  return dialect
83
117
 
84
118
  @cached_property
85
119
  def attach_uri_for_duckdb(self) -> str | None:
86
- if self.type == ConnectionTypeEnum.SQLALCHEMY:
120
+ if self.type == ConnectionTypeEnum.DUCKDB:
121
+ return self.uri
122
+ elif self.type == ConnectionTypeEnum.SQLALCHEMY:
87
123
  url = self.engine.url
88
124
  host = url.host
89
125
  port = url.port
90
126
  username = url.username
91
127
  password = url.password
92
128
  database = url.database
93
- sqlite_database = database if database is not None else ""
129
+ database_as_file = database if database is not None else ""
94
130
  else:
95
131
  url = urlparse(self.uri)
96
132
  host = url.hostname
@@ -98,22 +134,36 @@ class ConnectionProperties(BaseModel):
98
134
  username = url.username
99
135
  password = url.password
100
136
  database = url.path.lstrip('/')
101
- sqlite_database = self.uri.replace(f"{self.dialect}://", "")
137
+ database_as_file = self.uri.replace(f"{self.dialect}://", "")
102
138
 
103
- if self.dialect == 'sqlite':
104
- return sqlite_database
105
- elif self.dialect in ('postgres', 'mysql'):
106
- return f"dbname={database} user={username} password={password} host={host} port={port}"
139
+ if self.dialect in ('postgres', 'mysql'):
140
+ attach_uri = f"{self.dialect}:dbname={database} user={username} password={password} host={host} port={port}"
141
+ elif self.dialect == "sqlite":
142
+ attach_uri = f"{self.dialect}:{database_as_file}"
143
+ elif self.dialect == "duckdb":
144
+ attach_uri = database_as_file
107
145
  else:
108
- return None
146
+ attach_uri = None
147
+
148
+ return attach_uri
109
149
 
110
150
 
111
151
  class DbConnConfig(ConnectionProperties, _ConfigWithNameBaseModel):
112
- def finalize_uri(self, base_path: str) -> Self:
113
- self.uri = self.uri.format(project_path=base_path)
152
+ def finalize_uri(self, project_path: str) -> Self:
153
+ self.uri = self.uri.format(project_path=project_path)
114
154
  return self
115
155
 
116
156
 
157
+ class ConfigurableOverride(BaseModel):
158
+ name: str
159
+ default: str
160
+
161
+
162
+ class ConfigurablesConfig(ConfigurableOverride):
163
+ label: str = ""
164
+ description: str = ""
165
+
166
+
117
167
  class ParametersConfig(BaseModel):
118
168
  type: str
119
169
  factory: str
@@ -129,8 +179,10 @@ class PermissionScope(Enum):
129
179
  class AnalyticsOutputConfig(_ConfigWithNameBaseModel):
130
180
  label: str = ""
131
181
  description: str = ""
132
- scope: PermissionScope = PermissionScope.PUBLIC
182
+ scope: PermissionScope | None = None
133
183
  parameters: list[str] | None = Field(default=None, description="The list of parameter names used by the dataset/dashboard")
184
+ configurables: list[ConfigurableOverride] = Field(default_factory=list)
185
+ project_configurables: dict[str, Any] | None = Field(default=None, exclude=True)
134
186
 
135
187
  @model_validator(mode="after")
136
188
  def finalize_label(self) -> Self:
@@ -140,7 +192,9 @@ class AnalyticsOutputConfig(_ConfigWithNameBaseModel):
140
192
 
141
193
  @field_validator("scope", mode="before")
142
194
  @classmethod
143
- def validate_scope(cls, value: str, info: ValidationInfo) -> PermissionScope:
195
+ def validate_scope(cls, value: Any, info: ValidationInfo) -> PermissionScope | None:
196
+ if value is None:
197
+ return None
144
198
  try:
145
199
  return PermissionScope[str(value).upper()]
146
200
  except KeyError as e:
@@ -148,15 +202,23 @@ class AnalyticsOutputConfig(_ConfigWithNameBaseModel):
148
202
  scope_list = [scope.name.lower() for scope in PermissionScope]
149
203
  raise ValueError(f'Scope "{value}" is invalid for dataset/dashboard "{name}". Scope must be one of {scope_list}') from e
150
204
 
151
-
152
- class DatasetTraitConfig(_ConfigWithNameBaseModel):
153
- default: Any
205
+ @model_validator(mode="after")
206
+ def validate_configurables(self) -> Self:
207
+ if self.project_configurables is not None:
208
+ for cfg_override in self.configurables:
209
+ if cfg_override.name not in self.project_configurables:
210
+ # Determine if it's a dataset or dashboard for better error message
211
+ class_name = self.__class__.__name__
212
+ type_str = "Dataset" if "Dataset" in class_name else "Dashboard" if "Dashboard" in class_name else "Asset"
213
+ raise ValueError(
214
+ f'{type_str} "{self.name}" references configurable "{cfg_override.name}" which is not defined '
215
+ f'in the project configurables'
216
+ )
217
+ return self
154
218
 
155
219
 
156
220
  class DatasetConfig(AnalyticsOutputConfig):
157
221
  model: str = ""
158
- traits: dict = Field(default_factory=dict)
159
- default_test_set: str = ""
160
222
 
161
223
  def __hash__(self) -> int:
162
224
  return hash("dataset_"+self.name)
@@ -168,14 +230,14 @@ class DatasetConfig(AnalyticsOutputConfig):
168
230
  return self
169
231
 
170
232
 
233
+ class TestSetsUserConfig(BaseModel):
234
+ access_level: Literal["admin", "member", "guest"] = "guest"
235
+ custom_fields: dict[str, Any] = Field(default_factory=dict)
236
+
171
237
  class TestSetsConfig(_ConfigWithNameBaseModel):
172
- datasets: list[str] | None = None
173
- user_attributes: dict[str, Any] | None = None
238
+ user: TestSetsUserConfig = Field(default_factory=TestSetsUserConfig)
174
239
  parameters: dict[str, Any] = Field(default_factory=dict)
175
-
176
- @property
177
- def is_authenticated(self) -> bool:
178
- return self.user_attributes is not None
240
+ configurables: dict[str, Any] = Field(default_factory=dict)
179
241
 
180
242
 
181
243
  class ManifestConfig(BaseModel):
@@ -183,11 +245,10 @@ class ManifestConfig(BaseModel):
183
245
  packages: list[PackageConfig] = Field(default_factory=list)
184
246
  connections: dict[str, DbConnConfig] = Field(default_factory=dict)
185
247
  parameters: list[ParametersConfig] = Field(default_factory=list)
248
+ configurables: dict[str, ConfigurablesConfig] = Field(default_factory=dict)
186
249
  selection_test_sets: dict[str, TestSetsConfig] = Field(default_factory=dict)
187
- dataset_traits: dict[str, DatasetTraitConfig] = Field(default_factory=dict)
188
250
  datasets: dict[str, DatasetConfig] = Field(default_factory=dict)
189
- base_path: str = "."
190
- env_vars: dict[str, str] = Field(default_factory=dict)
251
+ project_path: str = "."
191
252
 
192
253
  @field_validator("packages")
193
254
  @classmethod
@@ -199,13 +260,13 @@ class ManifestConfig(BaseModel):
199
260
  set_of_directories.add(package.directory)
200
261
  return packages
201
262
 
202
- @field_validator("connections", "selection_test_sets", "dataset_traits", "datasets", mode="before")
263
+ @field_validator("connections", "selection_test_sets", "datasets", "configurables", mode="before")
203
264
  @classmethod
204
265
  def names_are_unique(cls, values: list[dict] | dict[str, dict], info: ValidationInfo) -> dict[str, dict]:
205
266
  if isinstance(values, list):
206
267
  values_as_dict = {}
207
268
  for obj in values:
208
- name = obj["name"]
269
+ name = u.normalize_name(obj["name"])
209
270
  if name in values_as_dict:
210
271
  raise ValueError(f'In the {info.field_name} section, the name "{name}" was specified multiple times')
211
272
  values_as_dict[name] = obj
@@ -216,62 +277,79 @@ class ManifestConfig(BaseModel):
216
277
  @model_validator(mode="after")
217
278
  def finalize_connections(self) -> Self:
218
279
  for conn in self.connections.values():
219
- conn.finalize_uri(self.base_path)
280
+ conn.finalize_uri(self.project_path)
220
281
  return self
221
282
 
222
283
  @model_validator(mode="after")
223
- def validate_dataset_traits(self) -> Self:
224
- for dataset_name, dataset in self.datasets.items():
225
- # Validate that all trait keys in dataset.traits exist in dataset_traits
226
- for trait_key in dataset.traits.keys():
227
- if trait_key not in self.dataset_traits:
228
- raise ValueError(
229
- f'Dataset "{dataset_name}" references undefined trait "{trait_key}". '
230
- f'Traits must be defined with a default value in the dataset_traits section.'
231
- )
232
-
233
- # Set default values for any traits that are missing
234
- for trait_name, trait_config in self.dataset_traits.items():
235
- if trait_name not in dataset.traits:
236
- dataset.traits[trait_name] = trait_config.default
237
-
284
+ def validate_authentication_and_scopes(self) -> Self:
285
+ """
286
+ Enforce authentication rules:
287
+ - Set default scope based on auth_type if not specified.
288
+ - If auth_type is REQUIRED, no dataset may be PUBLIC.
289
+ """
290
+ is_auth_required = self.project_variables.auth_type == AuthType.REQUIRED
291
+ default_scope = PermissionScope.PROTECTED if is_auth_required else PermissionScope.PUBLIC
292
+
293
+ for ds in self.datasets.values():
294
+ if ds.scope is None:
295
+ ds.scope = default_scope
296
+
297
+ if is_auth_required:
298
+ invalid = [name for name, ds in self.datasets.items() if ds.scope == PermissionScope.PUBLIC]
299
+ if invalid:
300
+ raise ValueError(
301
+ "Authentication is required, so datasets cannot be public.\n "
302
+ f"Update the scope for the following datasets: {invalid}\n "
303
+ )
238
304
  return self
239
305
 
240
- def get_default_test_set(self, dataset_name: str) -> TestSetsConfig:
306
+ @model_validator(mode="after")
307
+ def validate_dataset_configurables(self) -> Self:
308
+ """
309
+ Validate that dataset configurables reference valid project-level configurables.
310
+ """
311
+ for dataset_cfg in self.datasets.values():
312
+ dataset_cfg.project_configurables = self.configurables
313
+ dataset_cfg.validate_configurables()
314
+ return self
315
+
316
+ def get_default_test_set(self) -> TestSetsConfig:
241
317
  """
242
318
  Raises KeyError if dataset name doesn't exist
243
319
  """
244
- default_name_1 = self.datasets[dataset_name].default_test_set
245
- default_name_2 = self.env_vars.get(c.SQRL_TEST_SETS_DEFAULT_NAME_USED, "default")
246
- default_name = default_name_1 if default_name_1 else default_name_2
247
- default_test_set = self.selection_test_sets.get(default_name, TestSetsConfig(name=default_name))
320
+ default_default_test_set = TestSetsConfig(name=c.DEFAULT_TEST_SET_NAME)
321
+ default_test_set = self.selection_test_sets.get(c.DEFAULT_TEST_SET_NAME, default_default_test_set)
248
322
  return default_test_set
249
323
 
250
- def get_applicable_test_sets(self, dataset: str) -> list[str]:
251
- applicable_test_sets = []
252
- for test_set_name, test_set_config in self.selection_test_sets.items():
253
- if test_set_config.datasets is None or dataset in test_set_config.datasets:
254
- applicable_test_sets.append(test_set_name)
255
- return applicable_test_sets
256
-
257
- def get_default_traits(self) -> dict[str, Any]:
258
- default_traits = {}
259
- for trait_name, trait_config in self.dataset_traits.items():
260
- default_traits[trait_name] = trait_config.default
261
- return default_traits
324
+ def get_default_configurables(self, *, overrides: list[ConfigurableOverride] = []) -> dict[str, str]:
325
+ """
326
+ Return a dictionary of configurable name to its default value.
327
+
328
+ Arguments:
329
+ overrides: A list of ConfigurableOverride objects to merge with the project-level defaults.
330
+ """
331
+ defaults: dict[str, str] = {}
332
+ for name, cfg in self.configurables.items():
333
+ defaults[name] = str(cfg.default)
334
+
335
+ # Apply explicit overrides if provided
336
+ for cfg_override in overrides:
337
+ defaults[cfg_override.name] = cfg_override.default
338
+
339
+ return defaults
262
340
 
263
341
 
264
342
  class ManifestIO:
265
-
266
343
  @classmethod
267
- def load_from_file(cls, logger: u.Logger, base_path: str, env_vars: dict[str, str]) -> ManifestConfig:
344
+ def load_from_file(cls, logger: u.Logger, project_path: str, env_vars_unformatted: dict[str, str]) -> ManifestConfig:
268
345
  start = time.time()
269
346
 
270
- raw_content = u.read_file(u.Path(base_path, c.MANIFEST_FILE))
271
- content = u.render_string(raw_content, base_path=base_path, env_vars=env_vars)
272
- manifest_content = yaml.safe_load(content)
347
+ raw_content = u.read_file(u.Path(project_path, c.MANIFEST_FILE))
348
+ content = u.render_string(raw_content, project_path=project_path, env_vars=env_vars_unformatted)
349
+ manifest_content: dict[str, Any] = yaml.safe_load(content)
350
+
273
351
  try:
274
- manifest_cfg = ManifestConfig(base_path=base_path, **manifest_content)
352
+ manifest_cfg = ManifestConfig(project_path=project_path, **manifest_content)
275
353
  except ValidationError as e:
276
354
  raise u.ConfigurationError(f"Failed to process {c.MANIFEST_FILE} file. " + str(e)) from e
277
355