getdango 0.0.1__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 (189) hide show
  1. dango/__init__.py +12 -0
  2. dango/cli/__init__.py +5 -0
  3. dango/cli/db_helpers.py +123 -0
  4. dango/cli/env_helpers.py +336 -0
  5. dango/cli/init.py +815 -0
  6. dango/cli/main.py +3252 -0
  7. dango/cli/model_wizard.py +519 -0
  8. dango/cli/oauth.py +436 -0
  9. dango/cli/schema_manager.py +361 -0
  10. dango/cli/source_wizard.py +665 -0
  11. dango/cli/utils.py +780 -0
  12. dango/cli/validate.py +549 -0
  13. dango/cli/wizard.py +309 -0
  14. dango/config/__init__.py +49 -0
  15. dango/config/exceptions.py +25 -0
  16. dango/config/loader.py +288 -0
  17. dango/config/models.py +406 -0
  18. dango/ingestion/__init__.py +18 -0
  19. dango/ingestion/csv_loader.py +749 -0
  20. dango/ingestion/dlt_runner.py +1257 -0
  21. dango/ingestion/dlt_sources/__init__.py +11 -0
  22. dango/ingestion/dlt_sources/airtable/README.md +87 -0
  23. dango/ingestion/dlt_sources/airtable/__init__.py +68 -0
  24. dango/ingestion/dlt_sources/asana_dlt/README.md +69 -0
  25. dango/ingestion/dlt_sources/asana_dlt/__init__.py +264 -0
  26. dango/ingestion/dlt_sources/asana_dlt/helpers.py +17 -0
  27. dango/ingestion/dlt_sources/asana_dlt/settings.py +144 -0
  28. dango/ingestion/dlt_sources/chess/README.md +64 -0
  29. dango/ingestion/dlt_sources/chess/__init__.py +167 -0
  30. dango/ingestion/dlt_sources/chess/helpers.py +21 -0
  31. dango/ingestion/dlt_sources/chess/settings.py +4 -0
  32. dango/ingestion/dlt_sources/facebook_ads/README.md +72 -0
  33. dango/ingestion/dlt_sources/facebook_ads/__init__.py +214 -0
  34. dango/ingestion/dlt_sources/facebook_ads/exceptions.py +5 -0
  35. dango/ingestion/dlt_sources/facebook_ads/helpers.py +256 -0
  36. dango/ingestion/dlt_sources/facebook_ads/settings.py +192 -0
  37. dango/ingestion/dlt_sources/facebook_ads/utils.py +49 -0
  38. dango/ingestion/dlt_sources/freshdesk/README.md +71 -0
  39. dango/ingestion/dlt_sources/freshdesk/__init__.py +76 -0
  40. dango/ingestion/dlt_sources/freshdesk/freshdesk_client.py +102 -0
  41. dango/ingestion/dlt_sources/freshdesk/settings.py +9 -0
  42. dango/ingestion/dlt_sources/github/README.md +60 -0
  43. dango/ingestion/dlt_sources/github/__init__.py +149 -0
  44. dango/ingestion/dlt_sources/github/helpers.py +193 -0
  45. dango/ingestion/dlt_sources/github/queries.py +115 -0
  46. dango/ingestion/dlt_sources/github/settings.py +10 -0
  47. dango/ingestion/dlt_sources/google_ads/README.md +88 -0
  48. dango/ingestion/dlt_sources/google_ads/__init__.py +163 -0
  49. dango/ingestion/dlt_sources/google_ads/helpers/__init__.py +0 -0
  50. dango/ingestion/dlt_sources/google_ads/helpers/data_processing.py +20 -0
  51. dango/ingestion/dlt_sources/google_ads/setup_script_gcp_oauth.py +55 -0
  52. dango/ingestion/dlt_sources/google_analytics/README.md +59 -0
  53. dango/ingestion/dlt_sources/google_analytics/__init__.py +153 -0
  54. dango/ingestion/dlt_sources/google_analytics/helpers/__init__.py +72 -0
  55. dango/ingestion/dlt_sources/google_analytics/helpers/data_processing.py +189 -0
  56. dango/ingestion/dlt_sources/google_analytics/settings.py +3 -0
  57. dango/ingestion/dlt_sources/google_analytics/setup_script_gcp_oauth.py +55 -0
  58. dango/ingestion/dlt_sources/google_sheets/README.md +97 -0
  59. dango/ingestion/dlt_sources/google_sheets/__init__.py +152 -0
  60. dango/ingestion/dlt_sources/google_sheets/helpers/__init__.py +1 -0
  61. dango/ingestion/dlt_sources/google_sheets/helpers/api_calls.py +147 -0
  62. dango/ingestion/dlt_sources/google_sheets/helpers/data_processing.py +349 -0
  63. dango/ingestion/dlt_sources/google_sheets/setup_script_gcp_oauth.py +61 -0
  64. dango/ingestion/dlt_sources/hubspot/README.md +76 -0
  65. dango/ingestion/dlt_sources/hubspot/__init__.py +537 -0
  66. dango/ingestion/dlt_sources/hubspot/helpers.py +251 -0
  67. dango/ingestion/dlt_sources/hubspot/settings.py +130 -0
  68. dango/ingestion/dlt_sources/hubspot/utils.py +29 -0
  69. dango/ingestion/dlt_sources/inbox/README.md +101 -0
  70. dango/ingestion/dlt_sources/inbox/__init__.py +179 -0
  71. dango/ingestion/dlt_sources/inbox/helpers.py +186 -0
  72. dango/ingestion/dlt_sources/inbox/settings.py +5 -0
  73. dango/ingestion/dlt_sources/jira/README.md +80 -0
  74. dango/ingestion/dlt_sources/jira/__init__.py +138 -0
  75. dango/ingestion/dlt_sources/jira/settings.py +30 -0
  76. dango/ingestion/dlt_sources/kafka/README.md +82 -0
  77. dango/ingestion/dlt_sources/kafka/__init__.py +134 -0
  78. dango/ingestion/dlt_sources/kafka/helpers.py +262 -0
  79. dango/ingestion/dlt_sources/kafka/sources/kafka/__init__.py +0 -0
  80. dango/ingestion/dlt_sources/kinesis/README.md +82 -0
  81. dango/ingestion/dlt_sources/kinesis/__init__.py +130 -0
  82. dango/ingestion/dlt_sources/kinesis/helpers.py +63 -0
  83. dango/ingestion/dlt_sources/matomo/README.md +81 -0
  84. dango/ingestion/dlt_sources/matomo/__init__.py +223 -0
  85. dango/ingestion/dlt_sources/matomo/helpers/__init__.py +1 -0
  86. dango/ingestion/dlt_sources/matomo/helpers/data_processing.py +104 -0
  87. dango/ingestion/dlt_sources/matomo/helpers/matomo_client.py +170 -0
  88. dango/ingestion/dlt_sources/matomo/settings.py +3 -0
  89. dango/ingestion/dlt_sources/mongodb/README.md +81 -0
  90. dango/ingestion/dlt_sources/mongodb/__init__.py +164 -0
  91. dango/ingestion/dlt_sources/mongodb/helpers.py +665 -0
  92. dango/ingestion/dlt_sources/mux/README.md +56 -0
  93. dango/ingestion/dlt_sources/mux/__init__.py +88 -0
  94. dango/ingestion/dlt_sources/mux/settings.py +4 -0
  95. dango/ingestion/dlt_sources/notion/README.md +52 -0
  96. dango/ingestion/dlt_sources/notion/__init__.py +84 -0
  97. dango/ingestion/dlt_sources/notion/helpers/__init__.py +0 -0
  98. dango/ingestion/dlt_sources/notion/helpers/client.py +164 -0
  99. dango/ingestion/dlt_sources/notion/helpers/database.py +78 -0
  100. dango/ingestion/dlt_sources/notion/settings.py +3 -0
  101. dango/ingestion/dlt_sources/personio/README.md +87 -0
  102. dango/ingestion/dlt_sources/personio/__init__.py +330 -0
  103. dango/ingestion/dlt_sources/personio/helpers.py +85 -0
  104. dango/ingestion/dlt_sources/personio/settings.py +2 -0
  105. dango/ingestion/dlt_sources/pipedrive/README.md +78 -0
  106. dango/ingestion/dlt_sources/pipedrive/__init__.py +200 -0
  107. dango/ingestion/dlt_sources/pipedrive/helpers/__init__.py +20 -0
  108. dango/ingestion/dlt_sources/pipedrive/helpers/custom_fields_munger.py +102 -0
  109. dango/ingestion/dlt_sources/pipedrive/helpers/pages.py +115 -0
  110. dango/ingestion/dlt_sources/pipedrive/settings.py +29 -0
  111. dango/ingestion/dlt_sources/pipedrive/typing.py +4 -0
  112. dango/ingestion/dlt_sources/salesforce/README.md +131 -0
  113. dango/ingestion/dlt_sources/salesforce/__init__.py +148 -0
  114. dango/ingestion/dlt_sources/salesforce/helpers/__init__.py +0 -0
  115. dango/ingestion/dlt_sources/salesforce/helpers/client.py +214 -0
  116. dango/ingestion/dlt_sources/salesforce/helpers/records.py +121 -0
  117. dango/ingestion/dlt_sources/salesforce/settings.py +4 -0
  118. dango/ingestion/dlt_sources/shopify_dlt/README.md +61 -0
  119. dango/ingestion/dlt_sources/shopify_dlt/__init__.py +228 -0
  120. dango/ingestion/dlt_sources/shopify_dlt/exceptions.py +2 -0
  121. dango/ingestion/dlt_sources/shopify_dlt/helpers.py +146 -0
  122. dango/ingestion/dlt_sources/shopify_dlt/settings.py +5 -0
  123. dango/ingestion/dlt_sources/slack/README.md +95 -0
  124. dango/ingestion/dlt_sources/slack/__init__.py +288 -0
  125. dango/ingestion/dlt_sources/slack/helpers.py +205 -0
  126. dango/ingestion/dlt_sources/slack/settings.py +22 -0
  127. dango/ingestion/dlt_sources/strapi/README.md +58 -0
  128. dango/ingestion/dlt_sources/strapi/__init__.py +33 -0
  129. dango/ingestion/dlt_sources/strapi/helpers.py +42 -0
  130. dango/ingestion/dlt_sources/strapi/settings.py +1 -0
  131. dango/ingestion/dlt_sources/stripe_analytics/README.md +60 -0
  132. dango/ingestion/dlt_sources/stripe_analytics/__init__.py +118 -0
  133. dango/ingestion/dlt_sources/stripe_analytics/helpers.py +68 -0
  134. dango/ingestion/dlt_sources/stripe_analytics/metrics.py +95 -0
  135. dango/ingestion/dlt_sources/stripe_analytics/schemas.py +311 -0
  136. dango/ingestion/dlt_sources/stripe_analytics/settings.py +15 -0
  137. dango/ingestion/dlt_sources/workable/README.md +83 -0
  138. dango/ingestion/dlt_sources/workable/__init__.py +119 -0
  139. dango/ingestion/dlt_sources/workable/settings.py +30 -0
  140. dango/ingestion/dlt_sources/workable/workable_client.py +96 -0
  141. dango/ingestion/dlt_sources/zendesk/README.md +67 -0
  142. dango/ingestion/dlt_sources/zendesk/__init__.py +462 -0
  143. dango/ingestion/dlt_sources/zendesk/helpers/__init__.py +25 -0
  144. dango/ingestion/dlt_sources/zendesk/helpers/api_helpers.py +106 -0
  145. dango/ingestion/dlt_sources/zendesk/helpers/credentials.py +52 -0
  146. dango/ingestion/dlt_sources/zendesk/helpers/talk_api.py +116 -0
  147. dango/ingestion/dlt_sources/zendesk/settings.py +70 -0
  148. dango/ingestion/sources/__init__.py +9 -0
  149. dango/ingestion/sources/registry.py +1390 -0
  150. dango/platform/__init__.py +12 -0
  151. dango/platform/__main__.py +10 -0
  152. dango/platform/docker.py +380 -0
  153. dango/platform/network.py +509 -0
  154. dango/platform/watcher.py +531 -0
  155. dango/platform/watcher_runner.py +315 -0
  156. dango/templates/Dockerfile.metabase +40 -0
  157. dango/templates/__init__.py +5 -0
  158. dango/templates/dbt/schema.yml.j2 +21 -0
  159. dango/templates/dbt/sources.yml.j2 +48 -0
  160. dango/templates/dbt/staging_model.sql.j2 +31 -0
  161. dango/templates/docker-compose.yml.j2 +46 -0
  162. dango/templates/nginx.conf.j2 +110 -0
  163. dango/transformation/__init__.py +113 -0
  164. dango/transformation/generator.py +528 -0
  165. dango/utils/__init__.py +24 -0
  166. dango/utils/activity_log.py +54 -0
  167. dango/utils/data_validation.py +312 -0
  168. dango/utils/database.py +30 -0
  169. dango/utils/db_health.py +244 -0
  170. dango/utils/dbt_lock.py +236 -0
  171. dango/utils/dbt_status.py +106 -0
  172. dango/utils/sync_history.py +58 -0
  173. dango/visualization/__init__.py +9 -0
  174. dango/visualization/dashboard_manager.py +1102 -0
  175. dango/visualization/metabase.py +1046 -0
  176. dango/web/__init__.py +9 -0
  177. dango/web/app.py +2819 -0
  178. dango/web/static/css/main.css +200 -0
  179. dango/web/static/health.html +425 -0
  180. dango/web/static/index.html +528 -0
  181. dango/web/static/js/app.js +2154 -0
  182. dango/web/static/js/logs.js +473 -0
  183. dango/web/static/logs.html +251 -0
  184. getdango-0.0.1.dist-info/METADATA +300 -0
  185. getdango-0.0.1.dist-info/RECORD +189 -0
  186. getdango-0.0.1.dist-info/WHEEL +5 -0
  187. getdango-0.0.1.dist-info/entry_points.txt +2 -0
  188. getdango-0.0.1.dist-info/licenses/LICENSE +201 -0
  189. getdango-0.0.1.dist-info/top_level.txt +1 -0
dango/__init__.py ADDED
@@ -0,0 +1,12 @@
1
+ """
2
+ Dango - Local-first data platform
3
+
4
+ Laptop to cloud data stack in one weekend.
5
+ """
6
+
7
+ __version__ = "0.0.1"
8
+ __author__ = "Dango Team"
9
+ __license__ = "Apache-2.0"
10
+
11
+ # Package metadata
12
+ __all__ = ["__version__", "__author__", "__license__"]
dango/cli/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ """
2
+ Dango CLI module
3
+
4
+ Provides command-line interface for Dango data platform.
5
+ """
@@ -0,0 +1,123 @@
1
+ """
2
+ Database Helper Functions
3
+
4
+ Utilities for matching tables to source configurations, used by db status and db clean commands.
5
+ """
6
+
7
+ from typing import Dict, Set, Tuple
8
+ from dango.config import DangoConfig
9
+ from dango.ingestion.sources.registry import get_source_metadata
10
+
11
+
12
+ def build_schema_table_mapping(config: DangoConfig) -> Tuple[Dict[str, Set[str]], Dict[str, str]]:
13
+ """
14
+ Build mapping of schemas to expected tables based on source configurations.
15
+
16
+ For multi-resource sources (Stripe, Shopify, etc.):
17
+ - Schema: raw_{source_name} (e.g., raw_stripe_test)
18
+ - Tables: endpoint names (e.g., charge, customer, subscription)
19
+
20
+ For single-resource sources (CSV, etc.):
21
+ - Schema: raw
22
+ - Tables: source names (e.g., orders)
23
+
24
+ Args:
25
+ config: Dango configuration with source definitions
26
+
27
+ Returns:
28
+ Tuple of:
29
+ - schema_to_tables: Dict[schema_name, Set[table_names]]
30
+ - source_to_schema: Dict[source_name, schema_name]
31
+ """
32
+ schema_to_tables = {} # schema → set of table names
33
+ source_to_schema = {} # source_name → schema_name (for staging lookup)
34
+
35
+ for source in config.sources.sources:
36
+ source_name = source.name.lower()
37
+
38
+ # Get source metadata to check if multi-resource
39
+ metadata = get_source_metadata(source.type.value)
40
+ is_multi_resource = metadata.get("multi_resource", False) if metadata else False
41
+
42
+ if is_multi_resource:
43
+ # Multi-resource source: one schema per source, tables are endpoint names
44
+ schema_name = f"raw_{source_name}"
45
+ source_to_schema[source_name] = schema_name
46
+
47
+ # Get source-specific config
48
+ source_config = getattr(source, source.type.value, None)
49
+ if source_config:
50
+ source_dict = source_config.model_dump() if hasattr(source_config, 'model_dump') else {}
51
+ endpoints = source_dict.get('endpoints') or source_dict.get('resources') or source_dict.get('tables')
52
+
53
+ if endpoints:
54
+ if schema_name not in schema_to_tables:
55
+ schema_to_tables[schema_name] = set()
56
+ for endpoint in endpoints:
57
+ schema_to_tables[schema_name].add(endpoint.lower())
58
+ else:
59
+ # Single-resource source: 'raw' schema, table name is source name
60
+ if 'raw' not in schema_to_tables:
61
+ schema_to_tables['raw'] = set()
62
+ schema_to_tables['raw'].add(source_name)
63
+ source_to_schema[source_name] = 'raw'
64
+
65
+ return schema_to_tables, source_to_schema
66
+
67
+
68
+ def is_table_configured(
69
+ schema: str,
70
+ table: str,
71
+ schema_to_tables: Dict[str, Set[str]],
72
+ source_to_schema: Dict[str, str]
73
+ ) -> bool:
74
+ """
75
+ Check if a table is configured in sources.yml
76
+
77
+ Args:
78
+ schema: Table schema name
79
+ table: Table name
80
+ schema_to_tables: Mapping from schema to expected tables
81
+ source_to_schema: Mapping from source name to schema
82
+
83
+ Returns:
84
+ True if table is configured, False if orphaned
85
+ """
86
+ # Skip dlt internal tables (always considered configured)
87
+ if table.startswith('_dlt_'):
88
+ return True
89
+
90
+ # Raw tables: check schema-specific expected tables
91
+ if schema == 'raw' or schema.startswith('raw_'):
92
+ expected_in_schema = schema_to_tables.get(schema, set())
93
+ return table in expected_in_schema
94
+
95
+ # Staging tables: stg_{source_name}__{endpoint} or stg_{source_name}
96
+ elif schema == 'staging':
97
+ if table.startswith('stg_'):
98
+ # Try to match against source schemas
99
+ for source_name, raw_schema in source_to_schema.items():
100
+ # Check if staging table belongs to this source
101
+ if table.startswith(f"stg_{source_name}__") or table == f"stg_{source_name}":
102
+ # Extract endpoint/table name
103
+ if "__" in table:
104
+ endpoint = table.split("__", 1)[1]
105
+ else:
106
+ endpoint = source_name
107
+
108
+ # Check if this endpoint exists in the raw schema
109
+ expected_in_raw = schema_to_tables.get(raw_schema, set())
110
+ if endpoint in expected_in_raw or source_name in expected_in_raw:
111
+ return True
112
+ return False
113
+ else:
114
+ # Other staging tables - assume configured
115
+ return True
116
+
117
+ # Intermediate and marts tables - always assume configured
118
+ # (these are custom models, not auto-generated)
119
+ elif schema in ('intermediate', 'marts'):
120
+ return True
121
+
122
+ # Unknown schema - assume not configured
123
+ return False
@@ -0,0 +1,336 @@
1
+ """
2
+ .env File Helpers
3
+
4
+ Utilities for creating, validating, and managing .env files for credentials.
5
+ """
6
+
7
+ import os
8
+ import platform
9
+ import subprocess
10
+ from pathlib import Path
11
+ from typing import List, Dict, Set, Optional, Tuple
12
+ from rich.console import Console
13
+
14
+ console = Console()
15
+
16
+
17
+ def create_env_template(
18
+ env_file: Path,
19
+ env_vars: List[Dict[str, str]],
20
+ backup: bool = True
21
+ ) -> None:
22
+ """
23
+ Create or update .env file with helpful templates for required variables.
24
+
25
+ Uses atomic write pattern to prevent data loss on failure.
26
+
27
+ Args:
28
+ env_file: Path to .env file
29
+ env_vars: List of env var configs with name, help, format, example
30
+ backup: Whether to backup existing .env before modifying
31
+ """
32
+ env_file.parent.mkdir(parents=True, exist_ok=True)
33
+
34
+ # Read existing content (or start with header for new file)
35
+ if env_file.exists():
36
+ original_content = env_file.read_text()
37
+ existing_lines = original_content.splitlines()
38
+ else:
39
+ original_content = ""
40
+ existing_lines = [
41
+ "# Dango Data Platform - Environment Variables",
42
+ "# Add your credentials below",
43
+ ""
44
+ ]
45
+
46
+ # Backup existing file if requested
47
+ if backup and env_file.exists():
48
+ backup_file = env_file.with_suffix('.env.backup')
49
+ backup_file.write_text(original_content)
50
+
51
+ # Parse existing vars
52
+ existing_vars = set()
53
+ for line in existing_lines:
54
+ if '=' in line and not line.strip().startswith('#'):
55
+ var_name = line.split('=')[0].strip()
56
+ existing_vars.add(var_name)
57
+
58
+ # Build new vars section (only if not already present)
59
+ new_content = []
60
+ for var_config in env_vars:
61
+ var_name = var_config.get('name')
62
+
63
+ if var_name not in existing_vars:
64
+ # Add section header with source information
65
+ source_name = var_config.get('source_name')
66
+ source_type = var_config.get('source_type')
67
+
68
+ if source_name and source_type:
69
+ new_content.append(f"\n# {var_config.get('display_name', var_name)}")
70
+ new_content.append(f"# For source: '{source_name}' ({source_type})")
71
+ else:
72
+ new_content.append(f"\n# {var_config.get('display_name', var_name)}")
73
+
74
+ # Add help text
75
+ if 'help' in var_config:
76
+ new_content.append(f"# {var_config['help']}")
77
+
78
+ # Add format hint
79
+ if 'format' in var_config:
80
+ new_content.append(f"# Format: {var_config['format']}")
81
+
82
+ # Add example
83
+ if 'example' in var_config:
84
+ new_content.append(f"# Example: {var_config['example']}")
85
+
86
+ # Add empty var line
87
+ new_content.append(f"{var_name}=")
88
+ new_content.append("") # Blank line
89
+
90
+ # Atomic write: temp file + rename
91
+ if new_content or not env_file.exists():
92
+ temp_file = env_file.with_suffix('.env.tmp')
93
+ try:
94
+ # Write combined content to temp file
95
+ with open(temp_file, 'w') as f:
96
+ if existing_lines:
97
+ f.write('\n'.join(existing_lines))
98
+ if new_content:
99
+ f.write('\n'.join(new_content))
100
+
101
+ # Atomic rename
102
+ temp_file.replace(env_file)
103
+
104
+ except Exception as e:
105
+ # Clean up temp file and restore original if it existed
106
+ try:
107
+ if temp_file.exists():
108
+ temp_file.unlink()
109
+ except:
110
+ pass
111
+ raise Exception(f"Failed to update .env file: {e}")
112
+
113
+
114
+ def validate_env_file(
115
+ env_file: Path,
116
+ required_vars: List[str]
117
+ ) -> Tuple[bool, List[str]]:
118
+ """
119
+ Validate that .env file contains all required variables with non-empty values.
120
+
121
+ Args:
122
+ env_file: Path to .env file
123
+ required_vars: List of required variable names
124
+
125
+ Returns:
126
+ Tuple of (all_valid, missing_vars)
127
+ """
128
+ if not env_file.exists():
129
+ return False, required_vars
130
+
131
+ # Parse .env file
132
+ env_values = {}
133
+ for line in env_file.read_text().splitlines():
134
+ line = line.strip()
135
+ if line and not line.startswith('#') and '=' in line:
136
+ key, value = line.split('=', 1)
137
+ key = key.strip()
138
+ value = value.strip().strip('"').strip("'")
139
+ env_values[key] = value
140
+
141
+ # Check required vars
142
+ missing = []
143
+ for var in required_vars:
144
+ if var not in env_values or not env_values[var]:
145
+ missing.append(var)
146
+
147
+ return (len(missing) == 0, missing)
148
+
149
+
150
+ def open_file_in_default_app(filepath: Path) -> bool:
151
+ """
152
+ Open file in OS default application (non-blocking).
153
+
154
+ Args:
155
+ filepath: Path to file to open
156
+
157
+ Returns:
158
+ True if opened successfully, False otherwise
159
+ """
160
+ try:
161
+ system = platform.system()
162
+
163
+ if system == "Darwin": # macOS
164
+ subprocess.Popen(["open", str(filepath)])
165
+ elif system == "Windows":
166
+ subprocess.Popen(["start", str(filepath)], shell=True)
167
+ elif system == "Linux":
168
+ subprocess.Popen(["xdg-open", str(filepath)])
169
+ else:
170
+ return False
171
+
172
+ return True
173
+ except Exception:
174
+ return False
175
+
176
+
177
+ def guide_env_setup(
178
+ env_file: Path,
179
+ required_vars: List[Dict[str, str]],
180
+ source_name: str,
181
+ setup_guide: List[str] = None
182
+ ) -> bool:
183
+ """
184
+ Guide user through .env setup with optional file opening and validation.
185
+
186
+ Args:
187
+ env_file: Path to .env file
188
+ required_vars: List of required var configs (with name, help, etc.)
189
+ source_name: Name of source being configured
190
+ setup_guide: Optional list of setup instructions to display
191
+
192
+ Returns:
193
+ True if validated successfully, False otherwise
194
+ """
195
+ from inquirer import prompt, Confirm
196
+ from inquirer.themes import GreenPassion
197
+
198
+ console.print(f"\n[bold cyan]📝 Credentials Setup[/bold cyan]")
199
+ console.print(f"File: [dim]{env_file.absolute()}[/dim]\n")
200
+
201
+ # Show detailed setup guide if provided
202
+ if setup_guide:
203
+ console.print("[bold]How to get your credentials:[/bold]")
204
+ for step in setup_guide:
205
+ console.print(f" {step}")
206
+ console.print()
207
+
208
+ # Show required credentials
209
+ console.print("[bold]Required credentials:[/bold]")
210
+ for var_config in required_vars:
211
+ var_name = var_config.get('name')
212
+ help_text = var_config.get('help', '')
213
+ console.print(f" • {var_name}")
214
+ if help_text:
215
+ console.print(f" [dim]{help_text}[/dim]")
216
+
217
+ # Offer to open file
218
+ console.print()
219
+ questions = [
220
+ Confirm(
221
+ 'open_file',
222
+ message="Would you like me to open .env in your editor?",
223
+ default=True
224
+ )
225
+ ]
226
+ answers = prompt(questions, theme=GreenPassion())
227
+
228
+ if answers and answers['open_file']:
229
+ if open_file_in_default_app(env_file):
230
+ console.print("[green]✅ Opened .env in your default editor[/green]")
231
+ console.print("\n[bold cyan]Next steps:[/bold cyan]")
232
+ console.print(" 1. Find the section with empty values (e.g., STRIPE_API_KEY=)")
233
+ console.print(" 2. Paste your credential after the = sign")
234
+ console.print(" 3. [bold]SAVE THE FILE[/bold] (Cmd+S or Ctrl+S)")
235
+ console.print(" 4. Return here and press Enter")
236
+ else:
237
+ console.print("[yellow]⚠️ Couldn't open automatically[/yellow]")
238
+ console.print(f"Please manually open: {env_file}")
239
+ console.print("\n[bold cyan]Then:[/bold cyan]")
240
+ console.print(" 1. Find the section with empty values")
241
+ console.print(" 2. Paste your credentials after the = sign")
242
+ console.print(" 3. [bold]SAVE THE FILE[/bold]")
243
+
244
+ # Wait for user to finish
245
+ console.print("\n[bold yellow]⚠️ Press Enter AFTER you've saved the .env file[/bold yellow]")
246
+ input()
247
+
248
+ # Validate
249
+ var_names = [v.get('name') for v in required_vars]
250
+ is_valid, missing = validate_env_file(env_file, var_names)
251
+
252
+ return handle_validation_result(
253
+ is_valid, missing, env_file, source_name
254
+ )
255
+
256
+
257
+ def handle_validation_result(
258
+ is_valid: bool,
259
+ missing_vars: List[str],
260
+ env_file: Path,
261
+ source_name: str
262
+ ) -> bool:
263
+ """
264
+ Handle validation results with retry logic and graceful failure.
265
+
266
+ Args:
267
+ is_valid: Whether all required vars are present
268
+ missing_vars: List of missing variable names
269
+ env_file: Path to .env file
270
+ source_name: Name of source being configured
271
+
272
+ Returns:
273
+ True if validation passed or user chose to skip
274
+ """
275
+ from inquirer import prompt, List as InquirerList
276
+ from inquirer.themes import GreenPassion
277
+
278
+ if is_valid:
279
+ console.print("\n[green]✅ All credentials validated successfully![/green]")
280
+ return True
281
+
282
+ # Validation failed
283
+ console.print("\n[yellow]⚠️ Credential Validation Failed[/yellow]")
284
+ console.print("\nMissing or empty variables:")
285
+ for var in missing_vars:
286
+ console.print(f" • {var}")
287
+
288
+ console.print(f"\n[bold]Note:[/bold] Source '{source_name}' has been saved to sources.yml")
289
+ console.print("However, it won't work until you add valid credentials.\n")
290
+
291
+ # Offer retry
292
+ questions = [
293
+ InquirerList(
294
+ 'action',
295
+ message="What would you like to do?",
296
+ choices=[
297
+ 'Edit .env again and retry validation',
298
+ 'Skip validation (I\'ll add credentials later)',
299
+ 'Cancel source setup'
300
+ ],
301
+ )
302
+ ]
303
+ answers = prompt(questions, theme=GreenPassion())
304
+
305
+ if not answers:
306
+ return False
307
+
308
+ action = answers['action']
309
+
310
+ if action == 'Edit .env again and retry validation':
311
+ # Reopen file
312
+ if open_file_in_default_app(env_file):
313
+ console.print("[green]✅ Reopened .env[/green]")
314
+
315
+ console.print("\n[bold]Press Enter when ready to retry validation...[/bold]")
316
+ input()
317
+
318
+ # Retry validation
319
+ is_valid_retry, missing_retry = validate_env_file(env_file, missing_vars)
320
+ return handle_validation_result(
321
+ is_valid_retry, missing_retry, env_file, source_name
322
+ )
323
+
324
+ elif action == 'Skip validation (I\'ll add credentials later)':
325
+ console.print("\n[yellow]⚠️ Skipping validation[/yellow]")
326
+ console.print("\n[cyan]Next steps:[/cyan]")
327
+ console.print(f" 1. Edit .env and add missing credentials")
328
+ console.print(f" 2. Validate with: [bold]dango config validate[/bold]")
329
+ console.print(f" 3. Or try syncing: [bold]dango sync --source {source_name}[/bold]\n")
330
+ return True # Allow wizard to complete
331
+
332
+ else: # Cancel
333
+ console.print("\n[red]Source setup cancelled[/red]")
334
+ console.print(f"The source config was saved but is incomplete.")
335
+ console.print(f"To remove it, edit .dango/sources.yml\n")
336
+ return False