gaard-api 0.1.1__tar.gz → 0.1.2__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 (53) hide show
  1. {gaard_api-0.1.1/src/gaard_api.egg-info → gaard_api-0.1.2}/PKG-INFO +3 -2
  2. {gaard_api-0.1.1 → gaard_api-0.1.2}/pyproject.toml +3 -2
  3. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/services.py +15 -30
  4. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin-web/assets/main.js +74 -19
  5. gaard_api-0.1.2/src/gaard_api/admin-web/package.json +10 -0
  6. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin-web/src/main.ts +110 -15
  7. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/v1/admin.py +38 -13
  8. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/v1/prompts.py +4 -4
  9. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/v1/query.py +15 -11
  10. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/v1/schema.py +4 -4
  11. gaard_api-0.1.2/src/gaard_api/extensions.py +18 -0
  12. {gaard_api-0.1.1 → gaard_api-0.1.2/src/gaard_api.egg-info}/PKG-INFO +3 -2
  13. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api.egg-info/SOURCES.txt +2 -3
  14. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api.egg-info/requires.txt +2 -1
  15. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_admin_api.py +25 -0
  16. gaard_api-0.1.1/src/gaard_api/dependencies/__init__.py +0 -0
  17. gaard_api-0.1.1/src/gaard_api/schemas/__init__.py +0 -0
  18. gaard_api-0.1.1/src/gaard_api/services/__init__.py +0 -0
  19. {gaard_api-0.1.1 → gaard_api-0.1.2}/MANIFEST.in +0 -0
  20. {gaard_api-0.1.1 → gaard_api-0.1.2}/README.md +0 -0
  21. {gaard_api-0.1.1 → gaard_api-0.1.2}/setup.cfg +0 -0
  22. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/__init__.py +0 -0
  23. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/__init__.py +0 -0
  24. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/database.py +0 -0
  25. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/defaults.py +0 -0
  26. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/models.py +0 -0
  27. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/prompt_runtime.py +0 -0
  28. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin/security.py +0 -0
  29. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin-web/assets/styles.css +0 -0
  30. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/admin-web/index.html +0 -0
  31. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/__init__.py +0 -0
  32. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/api/v1/__init__.py +0 -0
  33. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/cli.py +0 -0
  34. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/cli_commands.py +0 -0
  35. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/core/__init__.py +0 -0
  36. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/core/error_handlers.py +0 -0
  37. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/core/schema_cache.py +0 -0
  38. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/core/settings.py +0 -0
  39. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/example_data/__init__.py +0 -0
  40. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/example_data/medical_poc/__init__.py +0 -0
  41. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/example_data/medical_poc/schema.sql +0 -0
  42. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/example_data/medical_poc/seed.sql +0 -0
  43. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/example_database.py +0 -0
  44. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/main.py +0 -0
  45. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api/server_cli.py +0 -0
  46. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api.egg-info/dependency_links.txt +0 -0
  47. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api.egg-info/entry_points.txt +0 -0
  48. {gaard_api-0.1.1 → gaard_api-0.1.2}/src/gaard_api.egg-info/top_level.txt +0 -0
  49. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_error_handlers.py +0 -0
  50. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_investigation_error_messages.py +0 -0
  51. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_prompt_runtime.py +0 -0
  52. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_server_cli.py +0 -0
  53. {gaard_api-0.1.1 → gaard_api-0.1.2}/tests/test_settings.py +0 -0
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: gaard-core==0.1.0
8
- Requires-Dist: gaard-connectors==0.1.0
8
+ Requires-Dist: gaard-connectors==0.1.1
9
9
  Requires-Dist: gaard-llm==0.1.0
10
+ Requires-Dist: gaard-plugin-api<0.2.0,>=0.1.0
10
11
  Requires-Dist: fastapi>=0.111.0
11
12
  Requires-Dist: uvicorn[standard]>=0.30.0
12
13
  Requires-Dist: pydantic>=2.7.0
@@ -4,15 +4,16 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gaard-api"
7
- version = "0.1.1"
7
+ version = "0.1.2"
8
8
  description = "GAARD backend web services providing admin interface"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
 
12
12
  dependencies = [
13
13
  "gaard-core==0.1.0",
14
- "gaard-connectors==0.1.0",
14
+ "gaard-connectors==0.1.1",
15
15
  "gaard-llm==0.1.0",
16
+ "gaard-plugin-api>=0.1.0,<0.2.0",
16
17
  "fastapi>=0.111.0",
17
18
  "uvicorn[standard]>=0.30.0",
18
19
  "pydantic>=2.7.0",
@@ -5,12 +5,10 @@ from dataclasses import dataclass
5
5
  from datetime import UTC, datetime, timedelta
6
6
  from typing import Any
7
7
 
8
- from sqlalchemy import create_engine, text
9
8
  from sqlalchemy import delete, desc, select
10
9
  from sqlalchemy.exc import SQLAlchemyError
11
10
  from sqlalchemy.orm import Session
12
11
 
13
- from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
14
12
  from gaard_core.errors import LlmProviderError
15
13
  from gaard_core.llm_output import remove_thinking_blocks
16
14
  from gaard_core.query_pipeline.models import OutputClassification, QueryRequest, QueryResponse
@@ -33,6 +31,7 @@ from gaard_api.admin.models import (
33
31
  PromptTemplate,
34
32
  )
35
33
  from gaard_api.core.settings import settings
34
+ from gaard_api.extensions import get_connector_registry
36
35
 
37
36
 
38
37
  def json_dumps(value: Any) -> str:
@@ -43,24 +42,16 @@ def json_loads(value: str) -> Any:
43
42
  return json.loads(value or "{}")
44
43
 
45
44
 
46
- SUPPORTED_DATABASE_TYPES = {
47
- "sqlite": ("sqlite://", "sqlite"),
48
- "postgresql": ("postgresql://", "postgresql+psycopg://"),
49
- "mysql": ("mysql://", "mysql+pymysql://"),
50
- }
51
-
52
-
53
45
  def validate_datasource_url(database_type: str, database_url: str) -> None:
54
- prefixes = SUPPORTED_DATABASE_TYPES.get(database_type)
46
+ get_connector_registry().get(database_type).validate_database_url(database_url)
55
47
 
56
- if prefixes is None:
57
- raise ValueError("Unsupported datasource type.")
58
48
 
59
- if not database_url.startswith(prefixes):
60
- raise ValueError(
61
- f"Datasource URL for {database_type} must start with one of: "
62
- f"{', '.join(prefixes)}"
63
- )
49
+ def validate_datasource_configuration(
50
+ database_type: str,
51
+ database_url: str,
52
+ sql_dialect: str,
53
+ ) -> None:
54
+ get_connector_registry().validate(database_type, database_url, sql_dialect)
64
55
 
65
56
 
66
57
  def mask_database_url(database_url: str) -> str:
@@ -1896,18 +1887,7 @@ def set_active_datasource_connector(
1896
1887
 
1897
1888
 
1898
1889
  def test_datasource_connection(connector: DatasourceConnector) -> None:
1899
- connect_args = (
1900
- {"check_same_thread": False}
1901
- if connector.database_url.startswith("sqlite")
1902
- else {}
1903
- )
1904
- engine = create_engine(connector.database_url, connect_args=connect_args)
1905
-
1906
- try:
1907
- with engine.connect() as connection:
1908
- connection.execute(text("SELECT 1"))
1909
- finally:
1910
- engine.dispose()
1890
+ get_connector_registry().get(connector.database_type).connection_tester(connector.database_url)
1911
1891
 
1912
1892
 
1913
1893
  def get_datasource_schema_cache(
@@ -1935,7 +1915,12 @@ def introspect_datasource_connector(
1935
1915
  connector: DatasourceConnector,
1936
1916
  actor: str,
1937
1917
  ) -> DatasourceSchemaCache:
1938
- schema = SQLAlchemySchemaIntrospector(connector.database_url).introspect()
1918
+ schema = (
1919
+ get_connector_registry()
1920
+ .get(connector.database_type)
1921
+ .introspector_factory(connector.database_url)
1922
+ .introspect()
1923
+ )
1939
1924
  existing = get_datasource_schema_cache(session, connector.id)
1940
1925
  existing_settings = (
1941
1926
  json_loads(existing.table_settings_json) if existing is not None else {}
@@ -81,6 +81,7 @@ const state = {
81
81
  llmConfig: null,
82
82
  governancePolicy: null,
83
83
  datasources: [],
84
+ datasourceTypes: [],
84
85
  selectedDatasourceId: null,
85
86
  datasourceSchema: null,
86
87
  datasourceSchemaLoading: false,
@@ -946,7 +947,14 @@ function getSelectedDatasource() {
946
947
  }
947
948
  function renderDatasourceForm(connector) {
948
949
  const systemManaged = connector?.system_managed === true;
949
- const disabled = systemManaged ? "disabled" : "";
950
+ const selectedTypeKey = connector?.database_type || state.datasourceTypes[0]?.type_key || "";
951
+ const selectedType = getDatasourceType(selectedTypeKey);
952
+ const databaseUrlSchema = selectedType?.config_schema?.properties?.database_url;
953
+ const selectedSqlDialect = connector?.sql_dialect || selectedType?.default_sql_dialect || "";
954
+ const databaseUrl = connector?.database_url || String(databaseUrlSchema?.default || "");
955
+ const unavailableType = Boolean(selectedTypeKey && !selectedType);
956
+ const disabled = systemManaged || unavailableType || !selectedType ? "disabled" : "";
957
+ const connectorDescription = selectedType?.description || (unavailableType ? `Connector type '${selectedTypeKey}' is unavailable. Install or enable its plugin before editing this datasource.` : "No connector types are available. Install or enable a connector plugin.");
950
958
  return `
951
959
  <form id="datasource-form" class="form-grid">
952
960
  <input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
@@ -954,32 +962,74 @@ function renderDatasourceForm(connector) {
954
962
  <label>Connector key<input name="connector_key" ${connector || systemManaged ? "readonly" : ""} ${disabled} value="${escapeHtml(connector?.connector_key || "")}" /></label>
955
963
  <label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
956
964
  <div class="subgrid">
957
- <label>Database type<select name="database_type" ${disabled}>${renderDatabaseTypeOptions(connector?.database_type || "sqlite")}</select></label>
958
- <label>SQL dialect<select name="sql_dialect" ${disabled}>${renderSqlDialectOptions(connector?.sql_dialect || "sqlite")}</select></label>
965
+ <label>Connector type<select id="datasource-type" name="database_type" ${disabled}>${renderDatasourceTypeOptions(selectedTypeKey)}</select></label>
966
+ <label>SQL dialect<select id="datasource-sql-dialect" name="sql_dialect" ${disabled}>${renderSqlDialectOptions(selectedType, selectedSqlDialect)}</select></label>
959
967
  </div>
960
- <label>Database URL<input name="database_url" ${disabled} value="${escapeHtml(connector?.database_url || "sqlite:///./examples/medical-poc/demo.db")}" /></label>
968
+ <p id="datasource-type-description" class="muted">${escapeHtml(connectorDescription)}</p>
969
+ <label><span id="datasource-url-label">${escapeHtml(databaseUrlSchema?.title || "Database URL")}</span><input id="datasource-url" name="database_url" ${disabled} placeholder="${escapeHtml(databaseUrlSchema?.description || "")}" value="${escapeHtml(databaseUrl)}" /></label>
961
970
  <label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
962
971
  <div class="button-row">
963
- <button type="button" id="test-datasource">Test</button>
972
+ <button type="button" id="test-datasource" ${disabled}>Test</button>
964
973
  <button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
965
974
  <button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
966
975
  <button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
967
976
  </div>
968
977
  </form>`;
969
978
  }
970
- function renderDatabaseTypeOptions(selected) {
971
- return [
972
- "sqlite",
973
- "postgresql",
974
- "mysql"
975
- ].map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
979
+ function getDatasourceType(typeKey) {
980
+ return state.datasourceTypes.find((item)=>item.type_key === typeKey) || null;
976
981
  }
977
- function renderSqlDialectOptions(selected) {
978
- return [
979
- "sqlite",
980
- "postgres",
981
- "mysql"
982
- ].map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
982
+ function renderDatasourceTypeOptions(selected) {
983
+ const datasourceTypes = [
984
+ ...state.datasourceTypes
985
+ ];
986
+ if (selected && !getDatasourceType(selected)) {
987
+ datasourceTypes.unshift({
988
+ type_key: selected,
989
+ label: `${selected} (plugin unavailable)`,
990
+ description: "",
991
+ sql_dialects: [],
992
+ default_sql_dialect: "",
993
+ config_schema: {}
994
+ });
995
+ }
996
+ if (!datasourceTypes.length) {
997
+ return `<option value="" selected>No connector types available</option>`;
998
+ }
999
+ return datasourceTypes.map((item)=>`<option value="${escapeHtml(item.type_key)}" ${item.type_key === selected ? "selected" : ""}>${escapeHtml(item.label)}</option>`).join("");
1000
+ }
1001
+ function renderSqlDialectOptions(datasourceType, selected) {
1002
+ const dialects = [
1003
+ ...datasourceType?.sql_dialects || []
1004
+ ];
1005
+ if (selected && !dialects.includes(selected)) {
1006
+ dialects.unshift(selected);
1007
+ }
1008
+ if (!dialects.length) {
1009
+ return `<option value="" selected>No SQL dialect available</option>`;
1010
+ }
1011
+ return dialects.map((value)=>`<option value="${escapeHtml(value)}" ${selected === value ? "selected" : ""}>${escapeHtml(value)}</option>`).join("");
1012
+ }
1013
+ function syncDatasourceTypeFields(event) {
1014
+ const typeKey = event.currentTarget.value;
1015
+ const datasourceType = getDatasourceType(typeKey);
1016
+ const sqlDialect = document.querySelector("#datasource-sql-dialect");
1017
+ const description = document.querySelector("#datasource-type-description");
1018
+ const urlLabel = document.querySelector("#datasource-url-label");
1019
+ const urlInput = document.querySelector("#datasource-url");
1020
+ const databaseUrlSchema = datasourceType?.config_schema?.properties?.database_url;
1021
+ if (sqlDialect) {
1022
+ sqlDialect.innerHTML = renderSqlDialectOptions(datasourceType, datasourceType?.default_sql_dialect || "");
1023
+ }
1024
+ if (description) {
1025
+ description.textContent = datasourceType?.description || "Connector type is unavailable.";
1026
+ }
1027
+ if (urlLabel) {
1028
+ urlLabel.textContent = databaseUrlSchema?.title || "Database URL";
1029
+ }
1030
+ if (urlInput) {
1031
+ urlInput.placeholder = databaseUrlSchema?.description || "";
1032
+ }
983
1033
  }
984
1034
  function renderModeOptions(selected, values) {
985
1035
  return values.map((value)=>`<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`).join("");
@@ -1315,6 +1365,7 @@ function attachSectionHandlers() {
1315
1365
  document.querySelector("#llm-config-form")?.addEventListener("submit", saveLlmConfig);
1316
1366
  document.querySelector("#governance-policy-form")?.addEventListener("submit", saveGovernancePolicy);
1317
1367
  document.querySelector("#datasource-form")?.addEventListener("submit", saveDatasource);
1368
+ document.querySelector("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
1318
1369
  document.querySelector("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
1319
1370
  document.querySelector("#schema-show-enabled-only")?.addEventListener("change", (event)=>{
1320
1371
  syncDatasourceSchemaDraftFromForm();
@@ -1974,8 +2025,12 @@ async function loadPrompts() {
1974
2025
  render();
1975
2026
  }
1976
2027
  async function loadDatasources() {
1977
- const result = await api("/api/v1/admin/datasources");
1978
- state.datasources = result.items || [];
2028
+ const [datasources, datasourceTypes] = await Promise.all([
2029
+ api("/api/v1/admin/datasources"),
2030
+ api("/api/v1/admin/datasource-types")
2031
+ ]);
2032
+ state.datasources = datasources.items || [];
2033
+ state.datasourceTypes = datasourceTypes.items || [];
1979
2034
  if (!state.selectedDatasourceId || state.selectedDatasourceId === "new") {
1980
2035
  state.selectedDatasourceId = state.datasources[0]?.id || null;
1981
2036
  }
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "gaard-admin-web",
3
+ "private": true,
4
+ "scripts": {
5
+ "build": "esbuild src/main.ts --bundle --format=esm --target=es2020 --outfile=assets/main.js"
6
+ },
7
+ "devDependencies": {
8
+ "esbuild": "^0.25.0"
9
+ }
10
+ }
@@ -26,14 +26,31 @@ type DatasourceConnector = {
26
26
  id: number;
27
27
  connector_key: string;
28
28
  name: string;
29
- database_type: "sqlite" | "postgresql" | "mysql";
29
+ database_type: string;
30
30
  database_url: string;
31
31
  masked_database_url: string;
32
- sql_dialect: "sqlite" | "postgres" | "mysql";
32
+ sql_dialect: string;
33
33
  active: boolean;
34
34
  system_managed: boolean;
35
35
  };
36
36
 
37
+ type DatasourceTypeConfigSchema = {
38
+ properties?: Record<string, {
39
+ title?: string;
40
+ description?: string;
41
+ default?: unknown;
42
+ }>;
43
+ };
44
+
45
+ type DatasourceType = {
46
+ type_key: string;
47
+ label: string;
48
+ description: string;
49
+ sql_dialects: string[];
50
+ default_sql_dialect: string;
51
+ config_schema: DatasourceTypeConfigSchema;
52
+ };
53
+
37
54
  type OverviewDatasource = {
38
55
  connector_key: string;
39
56
  name: string;
@@ -108,6 +125,7 @@ type State = {
108
125
  llmConfig: any | null;
109
126
  governancePolicy: any | null;
110
127
  datasources: DatasourceConnector[];
128
+ datasourceTypes: DatasourceType[];
111
129
  selectedDatasourceId: number | "new" | null;
112
130
  datasourceSchema: any | null;
113
131
  datasourceSchemaLoading: boolean;
@@ -167,6 +185,7 @@ const state: State = {
167
185
  llmConfig: null,
168
186
  governancePolicy: null,
169
187
  datasources: [],
188
+ datasourceTypes: [],
170
189
  selectedDatasourceId: null,
171
190
  datasourceSchema: null,
172
191
  datasourceSchemaLoading: false,
@@ -1143,7 +1162,17 @@ function getSelectedDatasource(): DatasourceConnector | null {
1143
1162
 
1144
1163
  function renderDatasourceForm(connector: DatasourceConnector | null): string {
1145
1164
  const systemManaged = connector?.system_managed === true;
1146
- const disabled = systemManaged ? "disabled" : "";
1165
+ const selectedTypeKey = connector?.database_type || state.datasourceTypes[0]?.type_key || "";
1166
+ const selectedType = getDatasourceType(selectedTypeKey);
1167
+ const databaseUrlSchema = selectedType?.config_schema?.properties?.database_url;
1168
+ const selectedSqlDialect = connector?.sql_dialect || selectedType?.default_sql_dialect || "";
1169
+ const databaseUrl = connector?.database_url || String(databaseUrlSchema?.default || "");
1170
+ const unavailableType = Boolean(selectedTypeKey && !selectedType);
1171
+ const disabled = systemManaged || unavailableType || !selectedType ? "disabled" : "";
1172
+ const connectorDescription = selectedType?.description
1173
+ || (unavailableType
1174
+ ? `Connector type '${selectedTypeKey}' is unavailable. Install or enable its plugin before editing this datasource.`
1175
+ : "No connector types are available. Install or enable a connector plugin.");
1147
1176
  return `
1148
1177
  <form id="datasource-form" class="form-grid">
1149
1178
  <input type="hidden" name="id" value="${escapeHtml(connector?.id || "")}" />
@@ -1151,13 +1180,14 @@ function renderDatasourceForm(connector: DatasourceConnector | null): string {
1151
1180
  <label>Connector key<input name="connector_key" ${connector || systemManaged ? "readonly" : ""} ${disabled} value="${escapeHtml(connector?.connector_key || "")}" /></label>
1152
1181
  <label>Name<input name="name" ${disabled} value="${escapeHtml(connector?.name || "")}" /></label>
1153
1182
  <div class="subgrid">
1154
- <label>Database type<select name="database_type" ${disabled}>${renderDatabaseTypeOptions(connector?.database_type || "sqlite")}</select></label>
1155
- <label>SQL dialect<select name="sql_dialect" ${disabled}>${renderSqlDialectOptions(connector?.sql_dialect || "sqlite")}</select></label>
1183
+ <label>Connector type<select id="datasource-type" name="database_type" ${disabled}>${renderDatasourceTypeOptions(selectedTypeKey)}</select></label>
1184
+ <label>SQL dialect<select id="datasource-sql-dialect" name="sql_dialect" ${disabled}>${renderSqlDialectOptions(selectedType, selectedSqlDialect)}</select></label>
1156
1185
  </div>
1157
- <label>Database URL<input name="database_url" ${disabled} value="${escapeHtml(connector?.database_url || "sqlite:///./examples/medical-poc/demo.db")}" /></label>
1186
+ <p id="datasource-type-description" class="muted">${escapeHtml(connectorDescription)}</p>
1187
+ <label><span id="datasource-url-label">${escapeHtml(databaseUrlSchema?.title || "Database URL")}</span><input id="datasource-url" name="database_url" ${disabled} placeholder="${escapeHtml(databaseUrlSchema?.description || "")}" value="${escapeHtml(databaseUrl)}" /></label>
1158
1188
  <label class="inline-check"><input name="active" type="checkbox" ${connector?.active ? "checked" : ""} ${disabled} /> Active datasource</label>
1159
1189
  <div class="button-row">
1160
- <button type="button" id="test-datasource">Test</button>
1190
+ <button type="button" id="test-datasource" ${disabled}>Test</button>
1161
1191
  <button type="button" id="introspect-datasource" ${connector ? "" : "disabled"}>Schema introspection</button>
1162
1192
  <button type="button" id="activate-datasource" ${connector && !connector.active && !systemManaged ? "" : "disabled"}>Activate</button>
1163
1193
  <button class="primary" type="submit" ${systemManaged ? "disabled" : ""}>${connector ? "Save" : "Create"}</button>
@@ -1165,18 +1195,78 @@ function renderDatasourceForm(connector: DatasourceConnector | null): string {
1165
1195
  </form>`;
1166
1196
  }
1167
1197
 
1168
- function renderDatabaseTypeOptions(selected: string): string {
1169
- return ["sqlite", "postgresql", "mysql"]
1170
- .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1198
+ function getDatasourceType(typeKey: string): DatasourceType | null {
1199
+ return state.datasourceTypes.find(item => item.type_key === typeKey) || null;
1200
+ }
1201
+
1202
+ function renderDatasourceTypeOptions(selected: string): string {
1203
+ const datasourceTypes = [...state.datasourceTypes];
1204
+
1205
+ if (selected && !getDatasourceType(selected)) {
1206
+ datasourceTypes.unshift({
1207
+ type_key: selected,
1208
+ label: `${selected} (plugin unavailable)`,
1209
+ description: "",
1210
+ sql_dialects: [],
1211
+ default_sql_dialect: "",
1212
+ config_schema: {},
1213
+ });
1214
+ }
1215
+
1216
+ if (!datasourceTypes.length) {
1217
+ return `<option value="" selected>No connector types available</option>`;
1218
+ }
1219
+
1220
+ return datasourceTypes
1221
+ .map(item => `<option value="${escapeHtml(item.type_key)}" ${item.type_key === selected ? "selected" : ""}>${escapeHtml(item.label)}</option>`)
1171
1222
  .join("");
1172
1223
  }
1173
1224
 
1174
- function renderSqlDialectOptions(selected: string): string {
1175
- return ["sqlite", "postgres", "mysql"]
1176
- .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
1225
+ function renderSqlDialectOptions(datasourceType: DatasourceType | null, selected: string): string {
1226
+ const dialects = [...(datasourceType?.sql_dialects || [])];
1227
+
1228
+ if (selected && !dialects.includes(selected)) {
1229
+ dialects.unshift(selected);
1230
+ }
1231
+
1232
+ if (!dialects.length) {
1233
+ return `<option value="" selected>No SQL dialect available</option>`;
1234
+ }
1235
+
1236
+ return dialects
1237
+ .map(value => `<option value="${escapeHtml(value)}" ${selected === value ? "selected" : ""}>${escapeHtml(value)}</option>`)
1177
1238
  .join("");
1178
1239
  }
1179
1240
 
1241
+ function syncDatasourceTypeFields(event: Event): void {
1242
+ const typeKey = (event.currentTarget as HTMLSelectElement).value;
1243
+ const datasourceType = getDatasourceType(typeKey);
1244
+ const sqlDialect = document.querySelector<HTMLSelectElement>("#datasource-sql-dialect");
1245
+ const description = document.querySelector<HTMLElement>("#datasource-type-description");
1246
+ const urlLabel = document.querySelector<HTMLElement>("#datasource-url-label");
1247
+ const urlInput = document.querySelector<HTMLInputElement>("#datasource-url");
1248
+ const databaseUrlSchema = datasourceType?.config_schema?.properties?.database_url;
1249
+
1250
+ if (sqlDialect) {
1251
+ sqlDialect.innerHTML = renderSqlDialectOptions(
1252
+ datasourceType,
1253
+ datasourceType?.default_sql_dialect || "",
1254
+ );
1255
+ }
1256
+
1257
+ if (description) {
1258
+ description.textContent = datasourceType?.description || "Connector type is unavailable.";
1259
+ }
1260
+
1261
+ if (urlLabel) {
1262
+ urlLabel.textContent = databaseUrlSchema?.title || "Database URL";
1263
+ }
1264
+
1265
+ if (urlInput) {
1266
+ urlInput.placeholder = databaseUrlSchema?.description || "";
1267
+ }
1268
+ }
1269
+
1180
1270
  function renderModeOptions(selected: string, values: string[]): string {
1181
1271
  return values
1182
1272
  .map(value => `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`)
@@ -1533,6 +1623,7 @@ function attachSectionHandlers(): void {
1533
1623
  document.querySelector<HTMLFormElement>("#llm-config-form")?.addEventListener("submit", saveLlmConfig);
1534
1624
  document.querySelector<HTMLFormElement>("#governance-policy-form")?.addEventListener("submit", saveGovernancePolicy);
1535
1625
  document.querySelector<HTMLFormElement>("#datasource-form")?.addEventListener("submit", saveDatasource);
1626
+ document.querySelector<HTMLSelectElement>("#datasource-type")?.addEventListener("change", syncDatasourceTypeFields);
1536
1627
  document.querySelector<HTMLFormElement>("#datasource-schema-form")?.addEventListener("submit", saveDatasourceSchema);
1537
1628
  document.querySelector<HTMLInputElement>("#schema-show-enabled-only")?.addEventListener("change", event => {
1538
1629
  syncDatasourceSchemaDraftFromForm();
@@ -2252,8 +2343,12 @@ async function loadPrompts(): Promise<void> {
2252
2343
  }
2253
2344
 
2254
2345
  async function loadDatasources(): Promise<void> {
2255
- const result = await api<any>("/api/v1/admin/datasources");
2256
- state.datasources = result.items || [];
2346
+ const [datasources, datasourceTypes] = await Promise.all([
2347
+ api<{ items: DatasourceConnector[] }>("/api/v1/admin/datasources"),
2348
+ api<{ items: DatasourceType[] }>("/api/v1/admin/datasource-types"),
2349
+ ]);
2350
+ state.datasources = datasources.items || [];
2351
+ state.datasourceTypes = datasourceTypes.items || [];
2257
2352
  if (!state.selectedDatasourceId || state.selectedDatasourceId === "new") {
2258
2353
  state.selectedDatasourceId = state.datasources[0]?.id || null;
2259
2354
  }
@@ -4,7 +4,6 @@ from datetime import datetime
4
4
  from typing import Any, Annotated
5
5
 
6
6
  from fastapi import APIRouter, Depends, Header, HTTPException, status
7
- from gaard_connectors.sqlalchemy.executor import SQLAlchemyQueryExecutor
8
7
  from gaard_core.errors import (
9
8
  ConfigurationError,
10
9
  LlmProviderError,
@@ -92,10 +91,12 @@ from gaard_api.admin.services import (
92
91
  update_business_logic_suggestion_content,
93
92
  update_schema_table_settings,
94
93
  validate_datasource_url,
94
+ validate_datasource_configuration,
95
95
  )
96
96
  from gaard_api.api.v1.schema import get_schema_cache_key
97
97
  from gaard_api.core.schema_cache import schema_context_cache
98
98
  from gaard_api.core.settings import settings
99
+ from gaard_api.extensions import get_connector_registry
99
100
 
100
101
  router = APIRouter()
101
102
 
@@ -220,22 +221,22 @@ class OverviewWidgetFromQueryRequest(BaseModel):
220
221
  class DatasourceConnectorRequest(BaseModel):
221
222
  connector_key: str = Field(min_length=1, pattern=r"^[a-zA-Z0-9_-]+$")
222
223
  name: str = Field(min_length=1)
223
- database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
224
+ database_type: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
224
225
  database_url: str = Field(min_length=1)
225
- sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
226
+ sql_dialect: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
226
227
  active: bool = False
227
228
 
228
229
 
229
230
  class DatasourceConnectorUpdateRequest(BaseModel):
230
231
  name: str = Field(min_length=1)
231
- database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
232
+ database_type: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
232
233
  database_url: str = Field(min_length=1)
233
- sql_dialect: str = Field(pattern=r"^(sqlite|postgres|mysql)$")
234
+ sql_dialect: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
234
235
  active: bool = False
235
236
 
236
237
 
237
238
  class DatasourceConnectionTestRequest(BaseModel):
238
- database_type: str = Field(pattern=r"^(sqlite|postgresql|mysql)$")
239
+ database_type: str = Field(pattern=r"^[a-z][a-z0-9_-]{0,63}$")
239
240
  database_url: str = Field(min_length=1)
240
241
 
241
242
 
@@ -472,10 +473,15 @@ def execute_overview_sql(
472
473
  ) -> QueryResult:
473
474
  SelectOnlySqlValidator(dialect=connector.sql_dialect).validate(sql)
474
475
 
475
- return SQLAlchemyQueryExecutor(
476
- database_url=connector.database_url,
477
- max_rows=get_query_runtime_config(session).query_max_rows,
478
- ).execute(sql)
476
+ return (
477
+ get_connector_registry()
478
+ .get(connector.database_type)
479
+ .executor_factory(
480
+ connector.database_url,
481
+ get_query_runtime_config(session).query_max_rows,
482
+ )
483
+ .execute(sql)
484
+ )
479
485
 
480
486
 
481
487
  def generate_overview_widget_sql(
@@ -1502,6 +1508,16 @@ def get_datasources(
1502
1508
  }
1503
1509
 
1504
1510
 
1511
+ @router.get("/datasource-types")
1512
+ def get_datasource_types(
1513
+ user: AdminUser = Depends(get_current_admin),
1514
+ ) -> dict[str, Any]:
1515
+ return {
1516
+ "items": [definition.serialize() for definition in get_connector_registry().list()],
1517
+ "viewer": user.username,
1518
+ }
1519
+
1520
+
1505
1521
  @router.post("/datasources")
1506
1522
  def create_datasource(
1507
1523
  request: DatasourceConnectorRequest,
@@ -1515,7 +1531,11 @@ def create_datasource(
1515
1531
  )
1516
1532
 
1517
1533
  try:
1518
- validate_datasource_url(request.database_type, request.database_url)
1534
+ validate_datasource_configuration(
1535
+ request.database_type,
1536
+ request.database_url,
1537
+ request.sql_dialect,
1538
+ )
1519
1539
  except ValueError as exc:
1520
1540
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
1521
1541
 
@@ -1584,7 +1604,11 @@ def update_datasource(
1584
1604
  )
1585
1605
 
1586
1606
  try:
1587
- validate_datasource_url(request.database_type, request.database_url)
1607
+ validate_datasource_configuration(
1608
+ request.database_type,
1609
+ request.database_url,
1610
+ request.sql_dialect,
1611
+ )
1588
1612
  except ValueError as exc:
1589
1613
  raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
1590
1614
 
@@ -1695,12 +1719,13 @@ def test_datasource_from_request(
1695
1719
  request: DatasourceConnectionTestRequest,
1696
1720
  ) -> dict[str, Any]:
1697
1721
  validate_datasource_url(request.database_type, request.database_url)
1722
+ definition = get_connector_registry().get(request.database_type)
1698
1723
  connector = DatasourceConnector(
1699
1724
  connector_key="__preview__",
1700
1725
  name="__preview__",
1701
1726
  database_type=request.database_type,
1702
1727
  database_url=request.database_url,
1703
- sql_dialect=request.database_type if request.database_type != "postgresql" else "postgres",
1728
+ sql_dialect=definition.default_sql_dialect,
1704
1729
  active=False,
1705
1730
  )
1706
1731
  test_datasource_connection(connector)
@@ -1,7 +1,6 @@
1
1
  from fastapi import APIRouter
2
2
  from pydantic import BaseModel, Field
3
3
 
4
- from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
5
4
  from gaard_core.prompt_compiler.models import CompiledPrompt
6
5
  from gaard_core.prompt_compiler.models import SqlGenerationPromptRequest
7
6
  from gaard_core.prompt_compiler.sql_generation_prompt import SqlGenerationPromptCompiler
@@ -12,6 +11,7 @@ from gaard_api.admin.services import get_datasource_schema_context_safe, get_que
12
11
  from gaard_api.api.v1.schema import get_schema_cache_key
13
12
  from gaard_api.core.schema_cache import schema_context_cache
14
13
  from gaard_api.core.settings import settings
14
+ from gaard_api.extensions import get_connector_registry
15
15
 
16
16
  router = APIRouter()
17
17
 
@@ -32,9 +32,9 @@ def compile_sql_generation_prompt(
32
32
  sql_dialect = connector.sql_dialect
33
33
  else:
34
34
  sql_dialect = settings.gaard_sql_dialect
35
- introspector = SQLAlchemySchemaIntrospector(
36
- database_url=settings.gaard_datasource_url,
37
- )
35
+ introspector = get_connector_registry().detect_from_database_url(
36
+ settings.gaard_datasource_url
37
+ ).introspector_factory(settings.gaard_datasource_url)
38
38
  service = SchemaContextService(
39
39
  introspector=introspector,
40
40
  cache=schema_context_cache,
@@ -8,8 +8,6 @@ from typing import Any, Callable
8
8
  from fastapi import APIRouter
9
9
  from fastapi.responses import StreamingResponse
10
10
 
11
- from gaard_connectors.sqlalchemy.executor import SQLAlchemyQueryExecutor
12
- from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
13
11
  from gaard_core.errors import (
14
12
  ConfigurationError,
15
13
  LlmProviderError,
@@ -75,6 +73,7 @@ from gaard_api.admin.services import (
75
73
  from gaard_api.api.v1.schema import get_schema_cache_key
76
74
  from gaard_api.core.schema_cache import schema_context_cache
77
75
  from gaard_api.core.settings import settings
76
+ from gaard_api.extensions import get_connector_registry
78
77
 
79
78
  router = APIRouter()
80
79
 
@@ -169,9 +168,9 @@ def create_sql_generator(
169
168
  prompt_compiler=get_sql_generation_prompt_compiler(),
170
169
  )
171
170
 
172
- introspector = SQLAlchemySchemaIntrospector(
173
- database_url=settings.gaard_datasource_url,
174
- )
171
+ introspector = get_connector_registry().detect_from_database_url(
172
+ settings.gaard_datasource_url
173
+ ).introspector_factory(settings.gaard_datasource_url)
175
174
 
176
175
  schema_context_service = SchemaContextService(
177
176
  introspector=introspector,
@@ -315,9 +314,14 @@ def create_pipeline(datasource_context: DatasourceContext | None = None) -> Quer
315
314
  else settings.gaard_sql_dialect
316
315
  )
317
316
 
318
- executor = SQLAlchemyQueryExecutor(
319
- database_url=database_url,
320
- max_rows=runtime_config.query_max_rows,
317
+ connector_definition = (
318
+ get_connector_registry().get(datasource_context[0].database_type)
319
+ if datasource_context is not None
320
+ else get_connector_registry().detect_from_database_url(database_url)
321
+ )
322
+ executor = connector_definition.executor_factory(
323
+ database_url,
324
+ runtime_config.query_max_rows,
321
325
  )
322
326
  llm_config = (
323
327
  get_llm_runtime_config_safe()
@@ -376,9 +380,9 @@ def schema_and_business_logic_for_investigation(
376
380
  get_active_business_logic_prompt_safe(connector.id),
377
381
  )
378
382
 
379
- introspector = SQLAlchemySchemaIntrospector(
380
- database_url=settings.gaard_datasource_url,
381
- )
383
+ introspector = get_connector_registry().detect_from_database_url(
384
+ settings.gaard_datasource_url
385
+ ).introspector_factory(settings.gaard_datasource_url)
382
386
  schema_context_service = SchemaContextService(
383
387
  introspector=introspector,
384
388
  cache=schema_context_cache,
@@ -1,6 +1,5 @@
1
1
  from fastapi import APIRouter
2
2
 
3
- from gaard_connectors.sqlalchemy.introspector import SQLAlchemySchemaIntrospector
4
3
  from gaard_core.schema.context import SchemaContextService
5
4
  from gaard_core.schema.models import DatabaseSchema
6
5
 
@@ -10,6 +9,7 @@ from gaard_api.admin.services import (
10
9
  )
11
10
  from gaard_api.core.schema_cache import schema_context_cache
12
11
  from gaard_api.core.settings import settings
12
+ from gaard_api.extensions import get_connector_registry
13
13
 
14
14
  router = APIRouter()
15
15
 
@@ -29,9 +29,9 @@ def get_schema() -> DatabaseSchema:
29
29
  _connector, schema_cache = datasource_context
30
30
  return selected_schema_from_cache(schema_cache)
31
31
 
32
- introspector = SQLAlchemySchemaIntrospector(
33
- database_url=settings.gaard_datasource_url,
34
- )
32
+ introspector = get_connector_registry().detect_from_database_url(
33
+ settings.gaard_datasource_url
34
+ ).introspector_factory(settings.gaard_datasource_url)
35
35
 
36
36
  service = SchemaContextService(
37
37
  introspector=introspector,
@@ -0,0 +1,18 @@
1
+ from functools import lru_cache
2
+
3
+ from gaard_connectors import ConnectorRegistry, create_builtin_connector_registry
4
+ from gaard_plugin_api import ExtensionManager
5
+
6
+
7
+ @lru_cache
8
+ def get_extension_manager() -> ExtensionManager:
9
+ manager = ExtensionManager()
10
+ manager.discover()
11
+ return manager
12
+
13
+
14
+ @lru_cache
15
+ def get_connector_registry() -> ConnectorRegistry:
16
+ registry = create_builtin_connector_registry()
17
+ get_extension_manager().activate("connectors", registry)
18
+ return registry
@@ -1,12 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gaard-api
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: GAARD backend web services providing admin interface
5
5
  Requires-Python: >=3.11
6
6
  Description-Content-Type: text/markdown
7
7
  Requires-Dist: gaard-core==0.1.0
8
- Requires-Dist: gaard-connectors==0.1.0
8
+ Requires-Dist: gaard-connectors==0.1.1
9
9
  Requires-Dist: gaard-llm==0.1.0
10
+ Requires-Dist: gaard-plugin-api<0.2.0,>=0.1.0
10
11
  Requires-Dist: fastapi>=0.111.0
11
12
  Requires-Dist: uvicorn[standard]>=0.30.0
12
13
  Requires-Dist: pydantic>=2.7.0
@@ -5,6 +5,7 @@ src/gaard_api/__init__.py
5
5
  src/gaard_api/cli.py
6
6
  src/gaard_api/cli_commands.py
7
7
  src/gaard_api/example_database.py
8
+ src/gaard_api/extensions.py
8
9
  src/gaard_api/main.py
9
10
  src/gaard_api/server_cli.py
10
11
  src/gaard_api.egg-info/PKG-INFO
@@ -21,6 +22,7 @@ src/gaard_api/admin/prompt_runtime.py
21
22
  src/gaard_api/admin/security.py
22
23
  src/gaard_api/admin/services.py
23
24
  src/gaard_api/admin-web/index.html
25
+ src/gaard_api/admin-web/package.json
24
26
  src/gaard_api/admin-web/assets/main.js
25
27
  src/gaard_api/admin-web/assets/styles.css
26
28
  src/gaard_api/admin-web/src/main.ts
@@ -34,13 +36,10 @@ src/gaard_api/core/__init__.py
34
36
  src/gaard_api/core/error_handlers.py
35
37
  src/gaard_api/core/schema_cache.py
36
38
  src/gaard_api/core/settings.py
37
- src/gaard_api/dependencies/__init__.py
38
39
  src/gaard_api/example_data/__init__.py
39
40
  src/gaard_api/example_data/medical_poc/__init__.py
40
41
  src/gaard_api/example_data/medical_poc/schema.sql
41
42
  src/gaard_api/example_data/medical_poc/seed.sql
42
- src/gaard_api/schemas/__init__.py
43
- src/gaard_api/services/__init__.py
44
43
  tests/test_admin_api.py
45
44
  tests/test_error_handlers.py
46
45
  tests/test_investigation_error_messages.py
@@ -1,6 +1,7 @@
1
1
  gaard-core==0.1.0
2
- gaard-connectors==0.1.0
2
+ gaard-connectors==0.1.1
3
3
  gaard-llm==0.1.0
4
+ gaard-plugin-api<0.2.0,>=0.1.0
4
5
  fastapi>=0.111.0
5
6
  uvicorn[standard]>=0.30.0
6
7
  pydantic>=2.7.0
@@ -91,6 +91,31 @@ def change_password(client: TestClient, token: str) -> None:
91
91
  assert response.json()["must_change_password"] is False
92
92
 
93
93
 
94
+ def test_admin_lists_datasource_types_from_connector_registry(admin_client: TestClient) -> None:
95
+ token = login(admin_client)["token"]
96
+ change_password(admin_client, token)
97
+
98
+ response = admin_client.get(
99
+ "/api/v1/admin/datasource-types",
100
+ headers={"Authorization": f"Bearer {token}"},
101
+ )
102
+
103
+ assert response.status_code == 200
104
+ definitions = {item["type_key"]: item for item in response.json()["items"]}
105
+ assert definitions["sqlite"]["default_sql_dialect"] == "sqlite"
106
+ assert definitions["postgresql"]["default_sql_dialect"] == "postgres"
107
+ assert definitions["mysql"]["config_schema"]["required"] == ["database_url"]
108
+
109
+
110
+ def test_admin_web_loads_connector_types_from_the_registry_api(admin_client: TestClient) -> None:
111
+ response = admin_client.get("/admin/assets/main.js")
112
+
113
+ assert response.status_code == 200
114
+ assert 'api("/api/v1/admin/datasource-types")' in response.text
115
+ assert "plugin unavailable" in response.text
116
+ assert "renderDatabaseTypeOptions" not in response.text
117
+
118
+
94
119
  def test_system_seeded_mock_runtime_modes_are_migrated_to_current_defaults(
95
120
  tmp_path: Path,
96
121
  monkeypatch: pytest.MonkeyPatch,
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes