fpu-barometer-admin 0.3.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.
Files changed (43) hide show
  1. fpu_barometer_admin/__init__.py +6 -0
  2. fpu_barometer_admin/cli/__init__.py +5 -0
  3. fpu_barometer_admin/cli/commands.py +199 -0
  4. fpu_barometer_admin/cli/deploy.py +719 -0
  5. fpu_barometer_admin/connectors/__init__.py +56 -0
  6. fpu_barometer_admin/connectors/acled_connector.py +77 -0
  7. fpu_barometer_admin/connectors/base_connector.py +60 -0
  8. fpu_barometer_admin/connectors/cpj_connector.py +92 -0
  9. fpu_barometer_admin/connectors/ert_connector.py +134 -0
  10. fpu_barometer_admin/connectors/gdelt_connector.py +403 -0
  11. fpu_barometer_admin/connectors/mfrr_connector.py +171 -0
  12. fpu_barometer_admin/connectors/rr_connector.py +84 -0
  13. fpu_barometer_admin/connectors/static_sources.py +41 -0
  14. fpu_barometer_admin/connectors/vdem_connector.py +165 -0
  15. fpu_barometer_admin/handlers/__init__.py +6 -0
  16. fpu_barometer_admin/handlers/function_app.py +543 -0
  17. fpu_barometer_admin/processors/__init__.py +46 -0
  18. fpu_barometer_admin/processors/acled_processor.py +263 -0
  19. fpu_barometer_admin/processors/base_processor.py +23 -0
  20. fpu_barometer_admin/processors/cpj_processor.py +147 -0
  21. fpu_barometer_admin/processors/ert_processor.py +72 -0
  22. fpu_barometer_admin/processors/gdelt_processor.py +260 -0
  23. fpu_barometer_admin/processors/mfrr_processor.py +327 -0
  24. fpu_barometer_admin/processors/rr_processor.py +208 -0
  25. fpu_barometer_admin/processors/vdem_processor.py +70 -0
  26. fpu_barometer_admin/runners/__init__.py +19 -0
  27. fpu_barometer_admin/runners/definitions.py +159 -0
  28. fpu_barometer_admin/runners/runners.py +291 -0
  29. fpu_barometer_admin/runners/scheduler.py +148 -0
  30. fpu_barometer_admin/runners/seed.py +399 -0
  31. fpu_barometer_admin/schemas/__init__.py +1 -0
  32. fpu_barometer_admin/schemas/event.py +362 -0
  33. fpu_barometer_admin/schemas/predictor.py +418 -0
  34. fpu_barometer_admin/storage/__init__.py +39 -0
  35. fpu_barometer_admin/storage/catalog.py +359 -0
  36. fpu_barometer_admin/storage/factory.py +165 -0
  37. fpu_barometer_admin/storage/objects.py +463 -0
  38. fpu_barometer_admin/storage/reader.py +410 -0
  39. fpu_barometer_admin-0.3.0.dist-info/METADATA +27 -0
  40. fpu_barometer_admin-0.3.0.dist-info/RECORD +43 -0
  41. fpu_barometer_admin-0.3.0.dist-info/WHEEL +4 -0
  42. fpu_barometer_admin-0.3.0.dist-info/entry_points.txt +2 -0
  43. fpu_barometer_admin-0.3.0.dist-info/licenses/LICENSE.md +7 -0
@@ -0,0 +1,719 @@
1
+ """Azure deployment helpers for the FPU admin CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ import time
11
+ import uuid
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+ from typing import Any, Sequence
15
+
16
+ import requests
17
+
18
+ from fpu_barometer_admin.connectors.static_sources import STATIC_SOURCE_FILES
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class AzureDeployConfig:
23
+ resource_group: str = "fpu-research-rg"
24
+ location: str = "westeurope"
25
+ environment: str = "prod"
26
+ storage_container: str = "datasets"
27
+ project_root: Path | None = None
28
+ python_version: str = "3.10"
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class AzureDeploymentResult:
33
+ resource_group: str
34
+ location: str
35
+ environment: str
36
+ storage_account: str
37
+ storage_container: str
38
+ function_app: str
39
+ function_app_url: str
40
+ config_path: Path
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class AzureCodeRedeployConfig:
45
+ resource_group: str | None = None
46
+ function_app: str | None = None
47
+ project_root: Path | None = None
48
+ scheduler_cron: str = "0 15 2 * * *"
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class AzureCodeRedeployResult:
53
+ resource_group: str
54
+ function_app: str
55
+ function_app_url: str
56
+ scheduler_cron: str
57
+ config_path: Path
58
+
59
+
60
+ def generate_unique_name(
61
+ base_name: str, max_length: int = 60, *, allow_dash: bool = True
62
+ ) -> str:
63
+ """Generate an Azure-safe unique resource name."""
64
+
65
+ suffix = uuid.uuid4().hex[:8]
66
+ normalized = base_name.lower()
67
+ if not allow_dash:
68
+ normalized = "".join(
69
+ character for character in normalized if character.isalnum()
70
+ )
71
+ separator = ""
72
+ else:
73
+ normalized = "".join(
74
+ character
75
+ for character in normalized
76
+ if character.isalnum() or character == "-"
77
+ ).strip("-")
78
+ separator = "-"
79
+ available_length = max_length - len(separator) - len(suffix)
80
+ return f"{normalized[:available_length].rstrip('-')}{separator}{suffix}"
81
+
82
+
83
+ def check_prerequisites() -> dict[str, bool]:
84
+ """Check local tools needed to deploy to Azure."""
85
+
86
+ return {
87
+ "azure_cli": _command_available(["az", "--version"]),
88
+ "func_tools": _command_available(["func", "--version"]),
89
+ "python_build": _command_available(["python", "-m", "build", "--version"]),
90
+ }
91
+
92
+
93
+ def check_azure_login() -> bool:
94
+ """Return whether Azure CLI has an active login."""
95
+
96
+ try:
97
+ _run_command(["az", "account", "show"], "checking Azure CLI login", timeout=30)
98
+ except RuntimeError:
99
+ return False
100
+ return True
101
+
102
+
103
+ def wait_for_function_app(
104
+ function_app_name: str, resource_group: str, max_wait_seconds: int = 60
105
+ ) -> bool:
106
+ """Wait until Azure reports the Function App as running and reachable."""
107
+
108
+ print("Waiting for Function App to become ready...")
109
+ started = time.time()
110
+ while time.time() - started < max_wait_seconds:
111
+ try:
112
+ result = _run_command(
113
+ [
114
+ "az",
115
+ "functionapp",
116
+ "show",
117
+ "--name",
118
+ function_app_name,
119
+ "--resource-group",
120
+ resource_group,
121
+ "--query",
122
+ "state",
123
+ "--output",
124
+ "tsv",
125
+ ],
126
+ "checking function app state",
127
+ timeout=30,
128
+ )
129
+ if result.stdout.strip() == "Running":
130
+ response = requests.head(
131
+ f"https://{function_app_name}.azurewebsites.net", timeout=5
132
+ )
133
+ if response.status_code in {200, 401, 403, 404}:
134
+ return True
135
+ except Exception:
136
+ pass
137
+ time.sleep(1)
138
+ return False
139
+
140
+
141
+ def stage_function_app(wheel_path: Path | Sequence[Path], staging_dir: Path) -> Path:
142
+ """Create a deployable Azure Functions root around built package wheels."""
143
+
144
+ wheel_paths = [wheel_path] if isinstance(wheel_path, Path) else list(wheel_path)
145
+ if not wheel_paths:
146
+ raise RuntimeError("No wheels supplied for Function App staging")
147
+ if staging_dir.exists():
148
+ shutil.rmtree(staging_dir)
149
+ staging_dir.mkdir(parents=True)
150
+
151
+ for wheel in wheel_paths:
152
+ shutil.copy2(wheel, staging_dir / wheel.name)
153
+ (staging_dir / "function_app.py").write_text(
154
+ "from fpu_barometer_admin.handlers.function_app import app\n", encoding="utf-8"
155
+ )
156
+ (staging_dir / "host.json").write_text(
157
+ json.dumps(
158
+ {
159
+ "version": "2.0",
160
+ "extensionBundle": {
161
+ "id": "Microsoft.Azure.Functions.ExtensionBundle",
162
+ "version": "[4.*, 5.0.0)",
163
+ },
164
+ },
165
+ indent=2,
166
+ )
167
+ + "\n",
168
+ encoding="utf-8",
169
+ )
170
+ requirement_lines = [f"./{wheel.name}" for wheel in wheel_paths]
171
+ requirement_lines[-1] = f"{requirement_lines[-1]}[admin]"
172
+ (staging_dir / "requirements.txt").write_text(
173
+ "\n".join(requirement_lines) + "\n", encoding="utf-8"
174
+ )
175
+ return staging_dir
176
+
177
+
178
+ def deploy_azure(config: AzureDeployConfig) -> AzureDeploymentResult:
179
+ """Deploy Barometer's Azure Functions API and Blob storage."""
180
+
181
+ _assert_prerequisites()
182
+ if not check_azure_login():
183
+ raise RuntimeError("Not logged into Azure CLI. Run 'az login' first.")
184
+
185
+ project_root = _resolve_project_root(config.project_root)
186
+ storage_account = generate_unique_name("barometerdata", 24, allow_dash=False)
187
+ function_app = generate_unique_name(f"barometer-api-{config.environment}", 60)
188
+ function_app_url = f"https://{function_app}.azurewebsites.net"
189
+
190
+ print("Starting Barometer Azure deployment")
191
+ print(f"Resource group: {config.resource_group}")
192
+ print(f"Location: {config.location}")
193
+ print(f"Storage account: {storage_account}")
194
+ print(f"Function app: {function_app}")
195
+
196
+ _run_command(
197
+ [
198
+ "az",
199
+ "group",
200
+ "create",
201
+ "--name",
202
+ config.resource_group,
203
+ "--location",
204
+ config.location,
205
+ "--output",
206
+ "table",
207
+ ],
208
+ "creating resource group",
209
+ )
210
+ _run_command(
211
+ [
212
+ "az",
213
+ "storage",
214
+ "account",
215
+ "create",
216
+ "--name",
217
+ storage_account,
218
+ "--resource-group",
219
+ config.resource_group,
220
+ "--location",
221
+ config.location,
222
+ "--sku",
223
+ "Standard_LRS",
224
+ "--kind",
225
+ "StorageV2",
226
+ "--access-tier",
227
+ "Hot",
228
+ "--allow-blob-public-access",
229
+ "false",
230
+ "--enable-hierarchical-namespace",
231
+ "true",
232
+ "--output",
233
+ "table",
234
+ ],
235
+ "creating storage account",
236
+ )
237
+ connection_string = _run_command(
238
+ [
239
+ "az",
240
+ "storage",
241
+ "account",
242
+ "show-connection-string",
243
+ "--resource-group",
244
+ config.resource_group,
245
+ "--name",
246
+ storage_account,
247
+ "--query",
248
+ "connectionString",
249
+ "--output",
250
+ "tsv",
251
+ ],
252
+ "getting storage connection string",
253
+ ).stdout.strip()
254
+ _run_command(
255
+ [
256
+ "az",
257
+ "storage",
258
+ "container",
259
+ "create",
260
+ "--name",
261
+ config.storage_container,
262
+ "--connection-string",
263
+ connection_string,
264
+ "--public-access",
265
+ "off",
266
+ "--output",
267
+ "none",
268
+ ],
269
+ f"creating storage container {config.storage_container}",
270
+ )
271
+ upload_static_source_files(
272
+ project_root,
273
+ connection_string=connection_string,
274
+ container_name=config.storage_container,
275
+ )
276
+ _run_command(
277
+ [
278
+ "az",
279
+ "functionapp",
280
+ "create",
281
+ "--name",
282
+ function_app,
283
+ "--resource-group",
284
+ config.resource_group,
285
+ "--consumption-plan-location",
286
+ config.location,
287
+ "--storage-account",
288
+ storage_account,
289
+ "--runtime",
290
+ "python",
291
+ "--runtime-version",
292
+ config.python_version,
293
+ "--os-type",
294
+ "Linux",
295
+ "--functions-version",
296
+ "4",
297
+ ],
298
+ "creating function app",
299
+ )
300
+ _run_command(
301
+ [
302
+ "az",
303
+ "functionapp",
304
+ "config",
305
+ "appsettings",
306
+ "set",
307
+ "--name",
308
+ function_app,
309
+ "--resource-group",
310
+ config.resource_group,
311
+ "--settings",
312
+ f"FPU_STORAGE_BACKEND=azure",
313
+ f"FPU_AZURE_STORAGE_CONNECTION_STRING={connection_string}",
314
+ f"FPU_AZURE_STORAGE_CONTAINER={config.storage_container}",
315
+ "FPU_SCHEDULER_CRON=0 15 2 * * *",
316
+ "FUNCTIONS_WORKER_RUNTIME=python",
317
+ "FUNCTIONS_EXTENSION_VERSION=~4",
318
+ "AzureWebJobsFeatureFlags=EnableWorkerIndexing",
319
+ "FUNCTIONS_WORKER_PROCESS_COUNT=1",
320
+ "WEBSITE_MAX_DYNAMIC_APPLICATION_SCALE_OUT=1",
321
+ "--output",
322
+ "table",
323
+ ],
324
+ "configuring function app",
325
+ )
326
+ if not wait_for_function_app(function_app, config.resource_group):
327
+ print("Function App readiness check timed out; continuing with deployment.")
328
+
329
+ deploy_workspace = project_root / ".scratch" / "deploy"
330
+ try:
331
+ wheel_paths = build_wheels(project_root)
332
+ staging_dir = stage_function_app(
333
+ wheel_paths, deploy_workspace / "azure-functions"
334
+ )
335
+ _run_command(
336
+ [
337
+ "func",
338
+ "azure",
339
+ "functionapp",
340
+ "publish",
341
+ function_app,
342
+ "--python",
343
+ "--build",
344
+ "remote",
345
+ ],
346
+ "publishing function app",
347
+ timeout=900,
348
+ cwd=staging_dir,
349
+ )
350
+ except Exception:
351
+ shutil.rmtree(deploy_workspace, ignore_errors=True)
352
+ raise
353
+
354
+ result = AzureDeploymentResult(
355
+ resource_group=config.resource_group,
356
+ location=config.location,
357
+ environment=config.environment,
358
+ storage_account=storage_account,
359
+ storage_container=config.storage_container,
360
+ function_app=function_app,
361
+ function_app_url=function_app_url,
362
+ config_path=_write_deployment_config(
363
+ project_root,
364
+ config=config,
365
+ storage_account=storage_account,
366
+ function_app=function_app,
367
+ function_app_url=function_app_url,
368
+ ),
369
+ )
370
+ print("Deployment complete")
371
+ print(f"API: {result.function_app_url}")
372
+ return result
373
+
374
+
375
+ def redeploy_azure_code(config: AzureCodeRedeployConfig) -> AzureCodeRedeployResult:
376
+ """Redeploy code to an existing Function App without touching storage resources."""
377
+
378
+ _assert_prerequisites()
379
+ if not check_azure_login():
380
+ raise RuntimeError("Not logged into Azure CLI. Run 'az login' first.")
381
+
382
+ project_root = _resolve_project_root(config.project_root)
383
+ deployment_config = {}
384
+ if not (config.resource_group and config.function_app):
385
+ deployment_config = _read_deployment_config(project_root)
386
+ resource_group = config.resource_group or deployment_config.get("resourceGroup")
387
+ function_app = config.function_app or deployment_config.get("functionApp")
388
+ if not resource_group or not function_app:
389
+ raise RuntimeError(
390
+ "Redeploy needs a resource group and Function App. Pass --resource-group "
391
+ "and --function-app, or keep .scratch/deploy/azure-deployment.json."
392
+ )
393
+ function_app_url = (
394
+ f"https://{function_app}.azurewebsites.net"
395
+ if config.function_app
396
+ else deployment_config.get(
397
+ "functionAppUrl", f"https://{function_app}.azurewebsites.net"
398
+ )
399
+ )
400
+
401
+ print("Starting Barometer Azure code redeploy")
402
+ print(f"Resource group: {resource_group}")
403
+ print(f"Function app: {function_app}")
404
+ print("Storage resources: preserved")
405
+
406
+ _run_command(
407
+ [
408
+ "az",
409
+ "functionapp",
410
+ "config",
411
+ "appsettings",
412
+ "set",
413
+ "--name",
414
+ function_app,
415
+ "--resource-group",
416
+ resource_group,
417
+ "--settings",
418
+ f"FPU_SCHEDULER_CRON={config.scheduler_cron}",
419
+ "--output",
420
+ "table",
421
+ ],
422
+ "configuring scheduler app setting",
423
+ )
424
+
425
+ redeploy_workspace = project_root / ".scratch" / "redeploy"
426
+ try:
427
+ wheel_paths = build_wheels(project_root)
428
+ staging_dir = stage_function_app(
429
+ wheel_paths, redeploy_workspace / "azure-functions"
430
+ )
431
+ _run_command(
432
+ [
433
+ "func",
434
+ "azure",
435
+ "functionapp",
436
+ "publish",
437
+ function_app,
438
+ "--python",
439
+ "--build",
440
+ "remote",
441
+ ],
442
+ "publishing function app",
443
+ timeout=900,
444
+ cwd=staging_dir,
445
+ )
446
+ except Exception:
447
+ shutil.rmtree(redeploy_workspace, ignore_errors=True)
448
+ raise
449
+
450
+ result = AzureCodeRedeployResult(
451
+ resource_group=resource_group,
452
+ function_app=function_app,
453
+ function_app_url=function_app_url,
454
+ scheduler_cron=config.scheduler_cron,
455
+ config_path=project_root / ".scratch" / "deploy" / "azure-deployment.json",
456
+ )
457
+ print("Code redeploy complete")
458
+ print(f"API: {result.function_app_url}")
459
+ return result
460
+
461
+
462
+ def upload_static_source_files(
463
+ project_root: Path,
464
+ *,
465
+ connection_string: str,
466
+ container_name: str,
467
+ ) -> None:
468
+ """Upload deploy-time static Connector source files to logical storage."""
469
+
470
+ for source_file in STATIC_SOURCE_FILES:
471
+ source_path = project_root / source_file.local_path
472
+ if not source_path.exists():
473
+ raise RuntimeError(f"Static source file not found: {source_path}")
474
+ _run_command(
475
+ [
476
+ "az",
477
+ "storage",
478
+ "blob",
479
+ "upload",
480
+ "--container-name",
481
+ container_name,
482
+ "--name",
483
+ source_file.logical_path,
484
+ "--file",
485
+ str(source_path),
486
+ "--connection-string",
487
+ connection_string,
488
+ "--overwrite",
489
+ "true",
490
+ "--output",
491
+ "none",
492
+ ],
493
+ f"uploading static source {source_file.dataset}",
494
+ )
495
+
496
+
497
+ def build_wheel(project_root: Path) -> Path:
498
+ """Build and return the primary package wheel path."""
499
+
500
+ return build_wheels(project_root)[-1]
501
+
502
+
503
+ def build_wheels(project_root: Path) -> list[Path]:
504
+ """Build deployment package wheels into the workspace dist directory."""
505
+
506
+ dist = project_root / "dist"
507
+ if dist.exists():
508
+ shutil.rmtree(dist)
509
+ dist.mkdir(parents=True, exist_ok=True)
510
+ for package_root in _deployment_package_roots(project_root):
511
+ _run_command(
512
+ [
513
+ "python",
514
+ "-m",
515
+ "build",
516
+ "--wheel",
517
+ "--outdir",
518
+ str(dist),
519
+ str(package_root),
520
+ ],
521
+ f"building package wheel for {package_root.name}",
522
+ timeout=180,
523
+ cwd=project_root,
524
+ )
525
+ wheels = sorted(dist.glob("*.whl"))
526
+ if not wheels:
527
+ raise RuntimeError("Build completed but no wheel was produced")
528
+ return wheels
529
+
530
+
531
+ def _deployment_package_roots(project_root: Path) -> list[Path]:
532
+ workspace_packages = [project_root / "packages" / "fpu-barometer", project_root / "packages" / "fpu-barometer-admin"]
533
+ if all((package_root / "pyproject.toml").exists() for package_root in workspace_packages):
534
+ return workspace_packages
535
+ return [project_root]
536
+
537
+
538
+ def _read_deployment_config(project_root: Path) -> dict[str, Any]:
539
+ config_path = project_root / ".scratch" / "deploy" / "azure-deployment.json"
540
+ if not config_path.exists():
541
+ return {}
542
+ return json.loads(config_path.read_text(encoding="utf-8"))
543
+
544
+
545
+ def _write_deployment_config(
546
+ project_root: Path,
547
+ *,
548
+ config: AzureDeployConfig,
549
+ storage_account: str,
550
+ function_app: str,
551
+ function_app_url: str,
552
+ ) -> Path:
553
+ config_dir = project_root / ".scratch" / "deploy"
554
+ config_dir.mkdir(parents=True, exist_ok=True)
555
+ config_path = config_dir / "azure-deployment.json"
556
+ config_path.write_text(
557
+ json.dumps(
558
+ {
559
+ "resourceGroup": config.resource_group,
560
+ "location": config.location,
561
+ "environment": config.environment,
562
+ "storageAccount": storage_account,
563
+ "storageContainer": config.storage_container,
564
+ "functionApp": function_app,
565
+ "functionAppUrl": function_app_url,
566
+ "deployedAt": time.strftime("%Y-%m-%d %H:%M:%S"),
567
+ },
568
+ indent=2,
569
+ )
570
+ + "\n",
571
+ encoding="utf-8",
572
+ )
573
+ return config_path
574
+
575
+
576
+ def _assert_prerequisites() -> None:
577
+ prereqs = check_prerequisites()
578
+ missing = [name for name, available in prereqs.items() if not available]
579
+ if missing:
580
+ install_hints = {
581
+ "azure_cli": "Install Azure CLI and run 'az login'.",
582
+ "func_tools": "Install Azure Functions Core Tools v4.",
583
+ "python_build": "Install Python build support with 'python -m pip install build'.",
584
+ }
585
+ hints = " ".join(install_hints[name] for name in missing)
586
+ raise RuntimeError(
587
+ f"Missing deployment prerequisites: {', '.join(missing)}. {hints}"
588
+ )
589
+
590
+
591
+ def _command_available(args: Sequence[str]) -> bool:
592
+ try:
593
+ result = subprocess.run(
594
+ _resolve_command_args(args),
595
+ capture_output=True,
596
+ text=True,
597
+ timeout=30,
598
+ shell=False,
599
+ )
600
+ except (FileNotFoundError, subprocess.TimeoutExpired):
601
+ return False
602
+ return result.returncode == 0
603
+
604
+
605
+ def _run_command(
606
+ args: Sequence[str],
607
+ description: str,
608
+ *,
609
+ timeout: int = 300,
610
+ cwd: Path | None = None,
611
+ ) -> subprocess.CompletedProcess[str]:
612
+ resolved_args = _resolve_command_args(args)
613
+ result = subprocess.run(
614
+ resolved_args,
615
+ capture_output=True,
616
+ text=True,
617
+ timeout=timeout,
618
+ shell=False,
619
+ cwd=cwd,
620
+ )
621
+ if result.returncode != 0:
622
+ raise RuntimeError(
623
+ f"Failed while {description}.\n"
624
+ f"Command: {' '.join(_redact_args(resolved_args))}\n"
625
+ f"stdout:\n{_redact_text(result.stdout)}\n\n"
626
+ f"stderr:\n{_redact_text(result.stderr)}"
627
+ )
628
+ return result
629
+
630
+
631
+ def _resolve_command_args(args: Sequence[str]) -> list[str]:
632
+ resolved = list(args)
633
+ if resolved:
634
+ resolved[0] = _resolve_executable(str(resolved[0]))
635
+ return resolved
636
+
637
+
638
+ def _resolve_executable(executable: str) -> str:
639
+ if executable == "python":
640
+ return sys.executable
641
+ if os.path.dirname(executable):
642
+ return executable
643
+ path = shutil.which(executable)
644
+ if path:
645
+ return path
646
+ if os.name == "nt" and not Path(executable).suffix:
647
+ for suffix in (".cmd", ".exe", ".bat"):
648
+ path = shutil.which(f"{executable}{suffix}")
649
+ if path:
650
+ return path
651
+ return executable
652
+
653
+
654
+ def _redact_text(value: str) -> str:
655
+ redacted = value
656
+ markers = ("AccountKey=", "SharedAccessSignature=", "sig=")
657
+ for marker in markers:
658
+ start = redacted.find(marker)
659
+ while start != -1:
660
+ value_start = start + len(marker)
661
+ value_end = len(redacted)
662
+ for separator in (";", " ", "\n", "&"):
663
+ separator_index = redacted.find(separator, value_start)
664
+ if separator_index != -1:
665
+ value_end = min(value_end, separator_index)
666
+ redacted = redacted[:value_start] + "<redacted>" + redacted[value_end:]
667
+ start = redacted.find(marker, value_start + len("<redacted>"))
668
+ return redacted
669
+
670
+
671
+ def _redact_args(args: Sequence[str]) -> list[str]:
672
+ redacted: list[str] = []
673
+ secret_markers = (
674
+ "CONNECTION_STRING=",
675
+ "AccountKey=",
676
+ "SharedAccessSignature=",
677
+ "sig=",
678
+ )
679
+ for arg in args:
680
+ value = str(arg)
681
+ if any(marker in value for marker in secret_markers):
682
+ if "=" in value:
683
+ key = value.split("=", 1)[0]
684
+ redacted.append(f"{key}=<redacted>")
685
+ else:
686
+ redacted.append("<redacted>")
687
+ else:
688
+ redacted.append(value)
689
+ return redacted
690
+
691
+
692
+ def _resolve_project_root(project_root: Path | None) -> Path:
693
+ if project_root is not None:
694
+ root = Path(project_root)
695
+ if not (root / "pyproject.toml").exists():
696
+ raise RuntimeError(f"Project root does not contain pyproject.toml: {root}")
697
+ return root
698
+ for candidate in [Path.cwd(), *Path.cwd().parents]:
699
+ if (candidate / "pyproject.toml").exists():
700
+ return candidate
701
+ raise RuntimeError("Could not find project root containing pyproject.toml")
702
+
703
+
704
+ __all__ = [
705
+ "AzureCodeRedeployConfig",
706
+ "AzureCodeRedeployResult",
707
+ "AzureDeployConfig",
708
+ "AzureDeploymentResult",
709
+ "build_wheel",
710
+ "build_wheels",
711
+ "check_azure_login",
712
+ "check_prerequisites",
713
+ "deploy_azure",
714
+ "redeploy_azure_code",
715
+ "generate_unique_name",
716
+ "stage_function_app",
717
+ "upload_static_source_files",
718
+ "wait_for_function_app",
719
+ ]