envshield 1.2.0__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.
@@ -0,0 +1,299 @@
1
+ # envshield/core/profile_manager.py
2
+ # Contains the core business logic for managing environment profiles.
3
+
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ from typing import List, Union
8
+
9
+ import questionary
10
+ import typer
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.prompt import Prompt
14
+ from rich.table import Table
15
+
16
+ from envshield import state
17
+ from envshield.config import manager as config_manager
18
+ from envshield.core import file_updater
19
+ from envshield.core.exceptions import ProfileNotFoundError, SourceFileNotFoundError
20
+ from envshield.parsers.factory import get_parser
21
+
22
+ console = Console()
23
+
24
+
25
+ def list_profiles():
26
+ """
27
+ Loads profiles from the config and displays them in a formatted table.
28
+ """
29
+ config = config_manager.load_config()
30
+ profiles = config.get("profiles", {})
31
+ active_profile = state.get_active_profile()
32
+ if not profiles:
33
+ console.print("[yellow]No profiles found in 'envshield.yml'.[/yellow]")
34
+ return
35
+ table = Table(
36
+ title="Available Profiles",
37
+ border_style="blue",
38
+ show_header=True,
39
+ header_style="bold blue",
40
+ )
41
+ table.add_column("Active", justify="center", style="green")
42
+ table.add_column("Profile Name", style="cyan", no_wrap=True)
43
+ table.add_column("Description", style="white")
44
+ for name, details in profiles.items():
45
+ is_active = "✓" if name == active_profile else ""
46
+ table.add_row(is_active, name, details.get("description", ""))
47
+ console.print(table)
48
+
49
+
50
+ def use_profile(profile_name: str):
51
+ """
52
+ Switches the active environment by processing the 'links' for the profile.
53
+ For each link, it creates a symlink from the source to the target.
54
+
55
+ Args:
56
+ profile_name: The name of the profile to activate.
57
+ """
58
+ config = config_manager.load_config()
59
+ profiles = config.get("profiles", {})
60
+ if profile_name not in profiles:
61
+ raise ProfileNotFoundError(profile_name)
62
+ profile_details = profiles[profile_name]
63
+ links = profile_details.get("links", [])
64
+ if not links:
65
+ console.print(
66
+ f"[yellow]Warning:[/yellow] Profile '{profile_name}' has no 'links' defined. No action taken."
67
+ )
68
+ return
69
+
70
+ console.print(f"Switching to profile [bold cyan]{profile_name}[/bold cyan]...")
71
+
72
+ # --- Symlink Logic ---
73
+ for link in links:
74
+ source_file = link.get("source")
75
+ target_file = link.get("target")
76
+
77
+ if not source_file or not target_file:
78
+ console.print(
79
+ f"[yellow]Warning:[/yellow] Skipping invalid link in profile '{profile_name}': {link}"
80
+ )
81
+ continue
82
+ if not os.path.exists(source_file):
83
+ raise SourceFileNotFoundError(source_file)
84
+
85
+ # If the target file (.env) already exists, we must remove it first.
86
+ # It could be a real file or a symlink from a previous 'use' command.
87
+ # os.lexists() is used to check for symlinks themselves without following them.
88
+ if os.path.lexists(target_file):
89
+ os.remove(target_file)
90
+
91
+ # Create the symbolic link.
92
+ try:
93
+ os.symlink(source_file, target_file)
94
+ console.print(
95
+ f" [green]✓[/green] Linked [magenta]{source_file}[/magenta] -> [bold magenta]{target_file}[/bold magenta]"
96
+ )
97
+ except OSError as e:
98
+ console.print(
99
+ f"[bold red]Error:[/bold red] Failed to create symbolic link for '{target_file}': {e}"
100
+ )
101
+ # We stop on the first error to avoid a partially configured state.
102
+ raise
103
+
104
+ # Only set the profile as active if all links were successful.
105
+ state.set_active_profile(profile_name)
106
+ console.print("\n[bold green]Environment is now active![/bold green]")
107
+
108
+
109
+ def _run_script(script_command: Union[str, List[str]]):
110
+ """
111
+ Runs a single shell command or a list of shell commands.
112
+ Raises an exception if any script fails.
113
+ """
114
+ if not script_command:
115
+ return
116
+ commands = script_command if isinstance(script_command, list) else [script_command]
117
+ for command in commands:
118
+ console.print(
119
+ f"\n[bold]Executing script:[/bold] [bright_black]{command}[/bright_black]"
120
+ )
121
+ try:
122
+ process = subprocess.Popen(
123
+ command,
124
+ shell=True,
125
+ stdout=subprocess.PIPE,
126
+ stderr=subprocess.STDOUT,
127
+ text=True,
128
+ )
129
+ for line in process.stdout:
130
+ console.print(f"[dim] {line.strip()}[/dim]")
131
+ process.wait()
132
+ if process.returncode != 0:
133
+ raise subprocess.CalledProcessError(process.returncode, command)
134
+ console.print(f"[green]✓ Script finished successfully.[/green]")
135
+ except Exception as e:
136
+ console.print(f"[bold red]Error running script: {e}[/bold red]")
137
+ raise
138
+
139
+
140
+ def onboard_profile(profile_name: str):
141
+ """
142
+ Guides a new user through setting up a profile for the first time with
143
+ smarter, more versatile logic and pre/post scripts.
144
+ """
145
+ console.print(
146
+ Panel(
147
+ f"[bold cyan]Welcome to EnvShield Onboarding for the '[yellow]{profile_name}[/yellow]' profile![/bold cyan]\n\nThis wizard will get you set up and ready to code.",
148
+ title="✨ Onboarding ✨",
149
+ border_style="green",
150
+ )
151
+ )
152
+
153
+ config = config_manager.load_config()
154
+ profiles = config.get("profiles", {})
155
+
156
+ if profile_name not in profiles:
157
+ raise ProfileNotFoundError(profile_name)
158
+
159
+ profile_details = profiles[profile_name]
160
+ links = profile_details.get("links", [])
161
+ onboarding_prompts = profile_details.get("onboarding_prompts", [])
162
+ pre_script = profile_details.get("pre_onboard_script")
163
+ post_script = profile_details.get("post_onboard_script")
164
+
165
+ if not links:
166
+ console.print(
167
+ f"[yellow]Warning:[/yellow] Profile '{profile_name}' has no 'links' defined. Nothing to onboard."
168
+ )
169
+ return
170
+
171
+ # --- Step 0: Run Pre-Onboarding Script(s) ---
172
+ if pre_script:
173
+ console.print(
174
+ "\n[bold]Step 0: Running pre-onboarding validation script(s)...[/bold]"
175
+ )
176
+ _run_script(pre_script)
177
+
178
+ # --- Step 1: Create Missing Source Files ---
179
+ console.print(
180
+ "\n[bold]Step 1: Checking for necessary configuration files...[/bold]"
181
+ )
182
+ created_files = False
183
+ for link in links:
184
+ source_file = link.get("source")
185
+ template_file = link.get("template")
186
+ if source_file and not os.path.exists(source_file):
187
+ console.print(
188
+ f" [yellow]![/yellow] Source file [magenta]{source_file}[/magenta] not found. Attempting to create it..."
189
+ )
190
+
191
+ if template_file and os.path.exists(template_file):
192
+ try:
193
+ shutil.copy(template_file, source_file)
194
+ console.print(
195
+ f" [green]✓[/green] Created from template '{template_file}'."
196
+ )
197
+ created_files = True
198
+ except IOError as e:
199
+ console.print(
200
+ f" [red]Error:[/red] Could not create '{source_file}': {e}"
201
+ )
202
+ raise typer.Exit(code=1)
203
+ else:
204
+ open(source_file, "a").close()
205
+ console.print(
206
+ f" [green]✓[/green] Created a blank file (no template specified for this link)."
207
+ )
208
+ created_files = True
209
+
210
+ if created_files:
211
+ console.print("[green]✓ All necessary source files have been created.[/green]")
212
+ else:
213
+ console.print("[green]✓ All source files already exist.[/green]")
214
+
215
+ # --- Step 2 Populate and Save Secrets ---
216
+ console.print("\n[bold]Step 2: Let's configure your secrets.[/bold]")
217
+ placeholder_keywords = [
218
+ "changeme",
219
+ "ask",
220
+ "todo",
221
+ "example",
222
+ "dummy",
223
+ "placeholder",
224
+ "get from",
225
+ "your-",
226
+ ]
227
+ updates_by_file = {}
228
+ for link in links:
229
+ source_file = link.get("source")
230
+ if not source_file or not os.path.exists(source_file):
231
+ continue
232
+ try:
233
+ with open(source_file, "r") as f:
234
+ lines = f.readlines()
235
+ for line in lines:
236
+ line = line.strip()
237
+ if not line or line.startswith("#") or "=" not in line:
238
+ continue
239
+ key, value = line.split("=", 1)
240
+ key = key.strip()
241
+ raw_value = value.strip().strip("\"'")
242
+ is_explicit_prompt = key in onboarding_prompts
243
+ is_placeholder = (
244
+ not raw_value
245
+ or raw_value.lower() == "x"
246
+ or any(
247
+ keyword in raw_value.lower() for keyword in placeholder_keywords
248
+ )
249
+ )
250
+ if is_explicit_prompt or is_placeholder:
251
+ if any(
252
+ update["key"] == key
253
+ for updates in updates_by_file.values()
254
+ for update in updates
255
+ ):
256
+ continue
257
+ console.print(
258
+ f"\nFound variable to configure: [bold cyan]{key}[/bold cyan] in [magenta]{source_file}[/magenta]."
259
+ )
260
+ if raw_value:
261
+ console.print(
262
+ f" [dim]Current value/instruction: {raw_value}[/dim]"
263
+ )
264
+ new_value = Prompt.ask(
265
+ f" Please enter the value for [bold cyan]{key}[/bold cyan]",
266
+ password=True,
267
+ )
268
+ if source_file not in updates_by_file:
269
+ updates_by_file[source_file] = []
270
+ updates_by_file[source_file].append(
271
+ {"key": key, "value": new_value}
272
+ )
273
+ except IOError:
274
+ continue
275
+ if not updates_by_file:
276
+ console.print(
277
+ "[green]✓ No placeholders found to update. Your secrets appear to be configured.[/green]"
278
+ )
279
+ else:
280
+ for file_path, updates in updates_by_file.items():
281
+ file_updater.update_variables_in_file(file_path, updates)
282
+ console.print(
283
+ "[green]✓ All secrets have been securely saved to your local files.[/green]"
284
+ )
285
+
286
+ # --- Step 3: Final Activation ---
287
+ console.print("\n[bold]Step 3: Activating the environment...[/bold]")
288
+ use_profile(profile_name)
289
+
290
+ # --- Step 4: Run Post-Onboarding Script(s) ---
291
+ if post_script:
292
+ console.print(
293
+ "\n[bold]Step 4: Running post-onboarding setup script(s)...[/bold]"
294
+ )
295
+ _run_script(post_script)
296
+
297
+ console.print(
298
+ "\n[bold green]🎉 Onboarding Complete! Your environment is ready. 🎉[/bold green]"
299
+ )
@@ -0,0 +1,123 @@
1
+ # envshield/core/scanner.py
2
+ # The core secret scanning engine for EnvShield.
3
+
4
+ import re
5
+ from typing import Dict, List
6
+
7
+ from rich.console import Console
8
+
9
+ console = Console()
10
+
11
+ # A comprehensive, curated list of high-confidence regex patterns for common secrets,
12
+ # inspired by community projects like https://github.com/mazen160/secrets-patterns-db
13
+ SECRET_PATTERNS: List[Dict[str, str]] = [
14
+ # --- Generic High-Confidence Patterns ---
15
+ {
16
+ "name": "Generic API Key",
17
+ "pattern": r"(?i)(key|api(?!version)|token|secret|password|auth|credential)[a-z0-9_ .\-,]{0,25}\s*[:=]\s*['\"]([0-9a-zA-Z\-_=]{16,64})['\"]",
18
+ },
19
+ {
20
+ "name": "Private Key",
21
+ "pattern": r"-----BEGIN (?:EC|PGP|DSA|RSA|OPENSSH|ENCRYPTED)? ?PRIVATE KEY(?: BLOCK)?-----",
22
+ },
23
+ {
24
+ "name": "JSON Web Token (JWT)",
25
+ "pattern": r"\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b",
26
+ },
27
+ {
28
+ "name": "Database Connection String",
29
+ "pattern": r"(?i)(postgres|mysql|mongodb(?:\+srv)?|redis)://[^:]+:[^@]+@",
30
+ },
31
+ # --- Cloud & IaaS Providers ---
32
+ {
33
+ "name": "AWS Access Key ID",
34
+ "pattern": r"\b(A3T[A-Z0-9]|AKIA|AGPA|AIDA|AROA|AIPA|ANPA|ANVA|ASIA)[A-Z0-9]{16}\b",
35
+ },
36
+ {
37
+ "name": "AWS Secret Access Key",
38
+ "pattern": r"(?i)aws(.{0,20})?['\"][0-9a-zA-Z\/+=]{40}['\"]",
39
+ },
40
+ {"name": "Google Cloud API Key", "pattern": r"\bAIza[0-9A-Za-z\-_]{35}\b"},
41
+ {"name": "Google OAuth Access Token", "pattern": r"\bya29\.[0-9A-Za-z\-_]+\b"},
42
+ # --- Version Control & DevOps ---
43
+ {
44
+ "name": "GitHub Personal Access Token (Classic)",
45
+ "pattern": r"\bghp_[0-9a-zA-Z]{36}\b",
46
+ },
47
+ {
48
+ "name": "GitHub Personal Access Token (Fine-grained)",
49
+ "pattern": r"\bgithub_pat_[0-9a-zA-Z]{22}_[0-9a-zA-Z]{59}\b",
50
+ },
51
+ {"name": "GitHub OAuth Access Token", "pattern": r"\bgho_[0-9a-zA-Z]{36}\b"},
52
+ {"name": "GitHub App Token", "pattern": r"\b(ghu|ghs)_[0-9a-zA-Z]{36}\b"},
53
+ {
54
+ "name": "Terraform Cloud/Enterprise Token",
55
+ "pattern": r"\b[a-zA-Z0-9]+\.atlasv1\.[a-zA-Z0-9\-_=]{60,70}\b",
56
+ },
57
+ # --- Communication & Messaging ---
58
+ {"name": "Slack Token", "pattern": r"\bxox[baprs]-[0-9a-zA-Z]{10,48}\b"},
59
+ {"name": "Telegram Bot Token", "pattern": r"\b[0-9]{8,10}:[a-zA-Z0-9_-]{35}\b"},
60
+ {"name": "Twilio API Key", "pattern": r"\bSK[0-9a-fA-F]{32}\b"},
61
+ {
62
+ "name": "SendGrid API Key",
63
+ "pattern": r"\bSG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}\b",
64
+ },
65
+ {"name": "Mailchimp API Key", "pattern": r"\b[0-9a-f]{32}-us[0-9]{1,2}\b"},
66
+ {"name": "Mailgun API Key", "pattern": r"\bkey-[0-9a-zA-Z]{32}\b"},
67
+ # --- SaaS, PaaS & APIs ---
68
+ {
69
+ "name": "Stripe API Key",
70
+ "pattern": r"\b(sk|pk)_(test|live)_[0-9a-zA-Z]{24,99}\b",
71
+ },
72
+ {
73
+ "name": "Heroku API Key",
74
+ "pattern": r"(?i)heroku[a-z0-9_\- ]*['\"][0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}['\"]",
75
+ },
76
+ {
77
+ "name": "Discord Bot Token",
78
+ "pattern": r"\b[MN][A-Za-z\d]{23,25}\.[\w-]{6}\.[\w-]{27,}\b",
79
+ },
80
+ # --- Package Managers ---
81
+ {"name": "npm Token", "pattern": r"\bnpm_[a-zA-Z0-9]{36}\b"},
82
+ {
83
+ "name": "PyPI Upload Token",
84
+ "pattern": r"\bpypi-AgEIcHlwaS5vcmc[A-Za-z0-9-_]{50,1000}\b",
85
+ },
86
+ ]
87
+
88
+
89
+ def scan_file_for_secrets(file_path: str) -> List[Dict]:
90
+ """
91
+ Scans a single file for any of the defined secret patterns.
92
+
93
+ Args:
94
+ file_path: The path to the file to scan.
95
+
96
+ Returns:
97
+ A list of dictionaries, where each dictionary represents a found secret.
98
+ Returns an empty list if no secrets are found.
99
+ """
100
+ findings = []
101
+ try:
102
+ with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
103
+ # We read line by line to get the line number for reporting.
104
+ for line_num, line in enumerate(f, 1):
105
+ for secret in SECRET_PATTERNS:
106
+ # re.search finds the first occurrence in the line
107
+ if re.search(secret["pattern"], line):
108
+ findings.append(
109
+ {
110
+ "file_path": file_path,
111
+ "line_num": line_num,
112
+ "secret_type": secret["name"],
113
+ "line_content": line.strip(),
114
+ }
115
+ )
116
+ # We break after the first find per line to avoid duplicate reports
117
+ # for the same line if multiple patterns match.
118
+ break
119
+ except IOError:
120
+ # Fails silently if a file can't be opened (e.g., broken symlink)
121
+ return []
122
+
123
+ return findings
@@ -0,0 +1,184 @@
1
+ # envshield/core/template_manager.py
2
+ # Core logic for template checking and syncing, with per-link support.
3
+
4
+ import os
5
+ import questionary
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+ import datetime
9
+
10
+ from envshield.config import manager as config_manager
11
+ from envshield.core.exceptions import ProfileNotFoundError
12
+ from envshield.parsers.factory import get_parser
13
+
14
+ console = Console()
15
+
16
+
17
+ def _get_link_diff(link: dict):
18
+ """Helper to get the variable difference for a single link."""
19
+ source_file = link.get("source")
20
+ template_file = link.get("template")
21
+
22
+ if not source_file or not template_file:
23
+ return None, None
24
+
25
+ source_parser = get_parser(source_file)
26
+ template_parser = get_parser(template_file)
27
+
28
+ if not source_parser or not template_parser:
29
+ return None, None
30
+
31
+ try:
32
+ source_vars = source_parser.get_vars(source_file)
33
+ template_vars = template_parser.get_vars(template_file)
34
+ except FileNotFoundError:
35
+ return None, None # Handled by the calling function
36
+
37
+ extra_in_source = source_vars - template_vars
38
+ return extra_in_source, template_file
39
+
40
+
41
+ def check_template(profile_name: str):
42
+ """
43
+ Compares each link in a profile with its own specific template.
44
+ """
45
+ config = config_manager.load_config()
46
+ profiles = config.get("profiles", {})
47
+
48
+ if profile_name not in profiles:
49
+ raise ProfileNotFoundError(profile_name)
50
+
51
+ profile_details = profiles[profile_name]
52
+ links = profile_details.get("links", [])
53
+
54
+ console.print(
55
+ f"\n[bold]Template Check for Profile: [cyan]{profile_name}[/cyan][/bold]"
56
+ )
57
+
58
+ found_issues = False
59
+ for link in links:
60
+ source_file = link.get("source")
61
+ template_file = link.get("template")
62
+
63
+ if not template_file:
64
+ continue # Skip links without a template
65
+
66
+ console.print(
67
+ f"\n[bold]Checking Link:[/bold] [magenta]{source_file}[/magenta] vs [magenta]{template_file}[/magenta]"
68
+ )
69
+
70
+ try:
71
+ extra_vars, _ = _get_link_diff(link)
72
+ # We also need to get missing vars for the report
73
+ template_vars = get_parser(template_file).get_vars(template_file)
74
+ source_vars = get_parser(source_file).get_vars(source_file)
75
+ missing_vars = template_vars - source_vars
76
+
77
+ if not extra_vars and not missing_vars:
78
+ console.print("[green] ✓ In sync.[/green]")
79
+ continue
80
+
81
+ found_issues = True
82
+ table = Table(show_header=True, header_style="bold blue")
83
+ table.add_column("Status", style="cyan")
84
+ table.add_column("Variable Name", style="white")
85
+
86
+ for var in sorted(list(missing_vars)):
87
+ table.add_row("[red]Missing in Source[/red]", var)
88
+ for var in sorted(list(extra_vars)):
89
+ table.add_row("[yellow]Extra in Source[/yellow]", var)
90
+
91
+ console.print(table)
92
+
93
+ except FileNotFoundError:
94
+ console.print(
95
+ f"[red] Error: One of the files was not found. Please run 'envshield onboard'.[/red]"
96
+ )
97
+ found_issues = True
98
+
99
+ if found_issues:
100
+ console.print(
101
+ "\n[bold]Suggestion:[/bold] Run `envshield template sync` to interactively update your templates."
102
+ )
103
+ else:
104
+ console.print(
105
+ "\n[bold green]✓ All configured templates are perfectly in sync![/bold green]"
106
+ )
107
+
108
+
109
+ def _append_to_template(template_file: str, variables_to_add: list):
110
+ """Appends new variables to the specified template file."""
111
+ if not variables_to_add:
112
+ return
113
+
114
+ console.print(f"\nUpdating template file: [magenta]{template_file}[/magenta]")
115
+
116
+ is_python_file = template_file.endswith(".py")
117
+
118
+ try:
119
+ with open(template_file, "a") as f:
120
+ f.write(
121
+ "\n\n# === Variables added by EnvShield on {} ===\n".format(
122
+ datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
123
+ )
124
+ )
125
+ for var in sorted(variables_to_add):
126
+ if is_python_file:
127
+ line = f'{var} = "" # TODO: Add default value\n'
128
+ else: # .env style
129
+ line = f"{var}=\n"
130
+ f.write(line)
131
+ console.print("[green]✓ Template file updated successfully.[/green]")
132
+ except IOError as e:
133
+ console.print(f"[red]Error:[/red] Could not write to template file: {e}")
134
+
135
+
136
+ def sync_template(profile_name: str):
137
+ """
138
+ Interactively syncs templates for each link in a profile.
139
+ """
140
+ config = config_manager.load_config()
141
+ profiles = config.get("profiles", {})
142
+ if profile_name not in profiles:
143
+ raise ProfileNotFoundError(profile_name)
144
+
145
+ links = profiles[profile_name].get("links", [])
146
+
147
+ console.print(
148
+ f"\n[bold]Interactive Template Sync for Profile: [cyan]{profile_name}[/cyan][/bold]"
149
+ )
150
+
151
+ synced_something = False
152
+ for link in links:
153
+ try:
154
+ extra_vars, template_file = _get_link_diff(link)
155
+ except FileNotFoundError:
156
+ continue # Skip if files don't exist
157
+
158
+ if not extra_vars or not template_file:
159
+ continue
160
+
161
+ synced_something = True
162
+ console.print(
163
+ f"\nFound extra variables for template [magenta]{template_file}[/magenta]:"
164
+ )
165
+
166
+ try:
167
+ variables_to_add = questionary.checkbox(
168
+ f"Which variables to add to {template_file}?",
169
+ choices=[
170
+ questionary.Choice(title=var, checked=True)
171
+ for var in sorted(list(extra_vars))
172
+ ],
173
+ ).ask()
174
+ except (KeyboardInterrupt, TypeError):
175
+ console.print("\n[yellow]Sync cancelled by user.[/yellow]")
176
+ return
177
+
178
+ if variables_to_add:
179
+ _append_to_template(template_file, variables_to_add)
180
+
181
+ if not synced_something:
182
+ console.print(
183
+ "\n[bold green]✓ All templates are already up-to-date![/bold green]"
184
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ # envshield/parsers/_base.py
2
+ # Defines the abstract base class for all file parsers.
3
+
4
+ from abc import ABC, abstractmethod
5
+ from typing import Set
6
+
7
+
8
+ class BaseParser(ABC):
9
+ """
10
+ Abstract Base Class for file parsers.
11
+ Ensures that every parser implements a 'get_vars' method.
12
+ """
13
+
14
+ @abstractmethod
15
+ def get_vars(self, file_path: str) -> Set[str]:
16
+ """
17
+ Parses a file and extracts the set of defined variable names.
18
+
19
+ Args:
20
+ file_path: The path to the file to be parsed.
21
+
22
+ Returns:
23
+ A set of strings, where each string is a variable name.
24
+
25
+ Raises:
26
+ FileNotFoundError: If the file_path does not exist.
27
+ """
28
+ pass
@@ -0,0 +1,35 @@
1
+ # envshield/parsers/_dotenv.py
2
+ # A simple parser for key-value .env files.
3
+ import os
4
+ from typing import Set
5
+ from ._base import BaseParser
6
+
7
+
8
+ class DotenvParser(BaseParser):
9
+ """
10
+ Parses traditional .env files.
11
+ """
12
+
13
+ def get_vars(self, file_path: str) -> Set[str]:
14
+ """
15
+ Extracts variable names from a .env file.
16
+ - Ignores lines starting with '#' (comments).
17
+ - Ignores empty lines.
18
+ - Splits lines by the first '=' to get the key.
19
+ """
20
+ if not os.path.exists(file_path):
21
+ raise FileNotFoundError(f"File not found: {file_path}")
22
+
23
+ variables = set()
24
+ with open(file_path, "r") as f:
25
+ for line in f:
26
+ line = line.strip()
27
+ if not line or line.startswith("#"):
28
+ continue
29
+
30
+ if "=" in line:
31
+ key = line.split("=", 1)[0].strip()
32
+ if key: # Ensure key is not empty
33
+ variables.add(key)
34
+
35
+ return variables