mdb-engine 0.3.1__py3-none-any.whl → 0.4.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.
mdb_engine/__init__.py CHANGED
@@ -81,7 +81,7 @@ from .repositories import Entity, MongoRepository, Repository, UnitOfWork
81
81
  # Utilities
82
82
  from .utils import clean_mongo_doc, clean_mongo_docs
83
83
 
84
- __version__ = "0.3.1" # Patch version bump: Fix public route matching for mounted apps
84
+ __version__ = "0.4.1" # Patch version: Bug fixes for path prefix handling and test improvements
85
85
 
86
86
  __all__ = [
87
87
  # Core Engine
@@ -86,16 +86,35 @@ def _get_request_path(request: Request) -> str:
86
86
  """
87
87
  Get the request path relative to the mount point.
88
88
 
89
- For mounted apps (via create_multi_app), use request.scope["path"] which
90
- contains the path relative to the mount point. For non-mounted apps,
91
- fall back to request.url.path.
89
+ For mounted apps (via create_multi_app), strips the path prefix from
90
+ request.url.path using request.state.app_base_path. For non-mounted apps,
91
+ uses request.scope["path"] if available, otherwise falls back to request.url.path.
92
92
 
93
93
  This ensures public routes in manifests (which are relative paths like "/")
94
94
  match correctly when apps are mounted at prefixes like "/auth-hub".
95
95
  """
96
- # Use scope["path"] which is relative to mount point for mounted apps
97
- # Fall back to url.path for non-mounted apps
98
- return request.scope.get("path", request.url.path)
96
+ # Check if this is a mounted app with a path prefix
97
+ app_base_path = getattr(request.state, "app_base_path", None)
98
+ # Ensure app_base_path is a string (not a MagicMock in tests)
99
+ if app_base_path and isinstance(app_base_path, str):
100
+ # Ensure request.url.path is a string before calling startswith
101
+ url_path = str(request.url.path) if hasattr(request.url, "path") else None
102
+ if url_path and url_path.startswith(app_base_path):
103
+ # Strip the path prefix to get relative path
104
+ relative_path = url_path[len(app_base_path) :]
105
+ # Ensure path starts with / (handle case where prefix is entire path)
106
+ return relative_path if relative_path else "/"
107
+
108
+ # Fall back to scope["path"] for mounted apps (if available)
109
+ # This handles cases where Starlette/FastAPI sets it correctly
110
+ if "path" in request.scope:
111
+ return request.scope["path"]
112
+
113
+ # Default to url.path for non-mounted apps
114
+ # Ensure we return a string
115
+ if hasattr(request.url, "path"):
116
+ return str(request.url.path)
117
+ return "/"
99
118
 
100
119
 
101
120
  class SharedAuthMiddleware(BaseHTTPMiddleware):
mdb_engine/core/engine.py CHANGED
@@ -1529,7 +1529,8 @@ class MongoDBEngine:
1529
1529
  Checks:
1530
1530
  - All prefixes start with '/'
1531
1531
  - No prefix is a prefix of another (e.g., '/app' conflicts with '/app/v2')
1532
- - No conflicts with reserved paths ('/health', '/docs', '/openapi.json')
1532
+ - No conflicts with reserved paths ('/health', '/docs', '/openapi.json', '/_mdb')
1533
+ - Slug matches manifest slug (if manifest is readable)
1533
1534
 
1534
1535
  Args:
1535
1536
  apps: List of app configs with 'path_prefix' keys
@@ -1537,17 +1538,27 @@ class MongoDBEngine:
1537
1538
  Returns:
1538
1539
  Tuple of (is_valid, list_of_errors)
1539
1540
  """
1541
+
1540
1542
  errors: list[str] = []
1541
- reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc"}
1543
+ reserved_paths = {"/health", "/docs", "/openapi.json", "/redoc", "/_mdb"}
1542
1544
 
1543
1545
  # Extract path prefixes
1544
1546
  path_prefixes: list[str] = []
1545
1547
  for app_config in apps:
1546
- path_prefix = app_config.get("path_prefix", f"/{app_config.get('slug', 'unknown')}")
1548
+ slug = app_config.get("slug", "unknown")
1549
+ path_prefix = app_config.get("path_prefix", f"/{slug}")
1550
+
1547
1551
  if not path_prefix.startswith("/"):
1548
- app_slug = app_config.get("slug", "unknown")
1549
- errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: {app_slug})")
1552
+ errors.append(f"Path prefix '{path_prefix}' must start with '/' (app: '{slug}')")
1550
1553
  continue
1554
+
1555
+ # Check for common mistakes
1556
+ if path_prefix.endswith("/") and path_prefix != "/":
1557
+ logger.warning(
1558
+ f"Path prefix '{path_prefix}' ends with '/'. "
1559
+ f"Consider removing trailing slash for app '{slug}'"
1560
+ )
1561
+
1551
1562
  path_prefixes.append(path_prefix)
1552
1563
 
1553
1564
  # Check for conflicts with reserved paths
@@ -1555,33 +1566,171 @@ class MongoDBEngine:
1555
1566
  if prefix in reserved_paths:
1556
1567
  errors.append(
1557
1568
  f"Path prefix '{prefix}' conflicts with reserved path. "
1558
- "Reserved paths: /health, /docs, /openapi.json, /redoc"
1569
+ "Reserved paths: /health, /docs, /openapi.json, /redoc, /_mdb"
1559
1570
  )
1560
1571
 
1561
1572
  # Check for prefix conflicts (one prefix being a prefix of another)
1562
1573
  path_prefixes_sorted = sorted(path_prefixes)
1563
1574
  for i, prefix1 in enumerate(path_prefixes_sorted):
1564
1575
  for prefix2 in path_prefixes_sorted[i + 1 :]:
1565
- if prefix1.startswith(prefix2) or prefix2.startswith(prefix1):
1576
+ # Normalize by ensuring both end with / for comparison
1577
+ p1_norm = prefix1 if prefix1.endswith("/") else prefix1 + "/"
1578
+ p2_norm = prefix2 if prefix2.endswith("/") else prefix2 + "/"
1579
+
1580
+ if p1_norm.startswith(p2_norm) or p2_norm.startswith(p1_norm):
1581
+ # Find which apps these belong to for better error message
1582
+ app1_slug = next(
1583
+ (a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix1),
1584
+ "unknown",
1585
+ )
1586
+ app2_slug = next(
1587
+ (a.get("slug", "unknown") for a in apps if a.get("path_prefix") == prefix2),
1588
+ "unknown",
1589
+ )
1566
1590
  errors.append(
1567
- f"Path prefix conflict: '{prefix1}' and '{prefix2}' overlap. "
1591
+ f"Path prefix conflict: '{prefix1}' (app: '{app1_slug}') and "
1592
+ f"'{prefix2}' (app: '{app2_slug}') overlap. "
1568
1593
  "One cannot be a prefix of another."
1569
1594
  )
1570
1595
 
1571
1596
  # Check for duplicates
1572
1597
  if len(path_prefixes) != len(set(path_prefixes)):
1573
- seen = set()
1574
- for prefix in path_prefixes:
1598
+ seen = {}
1599
+ for app_config in apps:
1600
+ prefix = app_config.get("path_prefix")
1601
+ slug = app_config.get("slug", "unknown")
1575
1602
  if prefix in seen:
1576
- errors.append(f"Duplicate path prefix: '{prefix}'")
1577
- seen.add(prefix)
1603
+ first_slug = seen[prefix]
1604
+ errors.append(
1605
+ f"Duplicate path prefix: '{prefix}' used by both "
1606
+ f"'{first_slug}' and '{slug}'"
1607
+ )
1608
+ else:
1609
+ seen[prefix] = slug
1578
1610
 
1579
1611
  return len(errors) == 0, errors
1580
1612
 
1581
- def create_multi_app(
1613
+ def _discover_apps_from_directory(
1614
+ self,
1615
+ apps_dir: Path,
1616
+ path_prefix_template: str | None = None,
1617
+ ) -> list[dict[str, Any]]:
1618
+ """
1619
+ Auto-discover apps by scanning directory for manifest.json files.
1620
+
1621
+ Args:
1622
+ apps_dir: Directory to scan for apps
1623
+ path_prefix_template: Template for path prefixes (e.g., "/app-{index}")
1624
+
1625
+ Returns:
1626
+ List of app configurations
1627
+ """
1628
+ import json
1629
+
1630
+ apps_dir = Path(apps_dir)
1631
+ if not apps_dir.exists():
1632
+ raise ValueError(f"Apps directory does not exist: {apps_dir}")
1633
+
1634
+ discovered_apps = []
1635
+ manifest_files = list(apps_dir.rglob("manifest.json"))
1636
+
1637
+ if not manifest_files:
1638
+ raise ValueError(f"No manifest.json files found in {apps_dir}")
1639
+
1640
+ for idx, manifest_path in enumerate(sorted(manifest_files), start=1):
1641
+ try:
1642
+ with open(manifest_path) as f:
1643
+ manifest_data = json.load(f)
1644
+
1645
+ slug = manifest_data.get("slug")
1646
+ if not slug:
1647
+ logger.warning(f"Skipping manifest without slug: {manifest_path}")
1648
+ continue
1649
+
1650
+ # Generate path prefix
1651
+ if path_prefix_template:
1652
+ path_prefix = path_prefix_template.format(index=idx, slug=slug)
1653
+ else:
1654
+ path_prefix = f"/{slug}"
1655
+
1656
+ discovered_apps.append(
1657
+ {
1658
+ "slug": slug,
1659
+ "manifest": manifest_path,
1660
+ "path_prefix": path_prefix,
1661
+ }
1662
+ )
1663
+ logger.info(
1664
+ f"Discovered app '{slug}' at {manifest_path} " f"(will mount at {path_prefix})"
1665
+ )
1666
+ except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
1667
+ logger.warning(f"Failed to read manifest at {manifest_path}: {e}")
1668
+ continue
1669
+
1670
+ if not discovered_apps:
1671
+ raise ValueError(f"No valid apps discovered in {apps_dir}")
1672
+
1673
+ return discovered_apps
1674
+
1675
+ def _validate_manifests(self, apps: list[dict[str, Any]], strict: bool) -> list[str]:
1676
+ """Validate all app manifests."""
1677
+ import json
1678
+
1679
+ logger.info("Validating all manifests before mounting...")
1680
+ validation_errors = []
1681
+ for app_config in apps:
1682
+ slug = app_config.get("slug", "unknown")
1683
+ manifest_path = app_config.get("manifest")
1684
+ try:
1685
+ with open(manifest_path) as f:
1686
+ manifest_data = json.load(f)
1687
+
1688
+ # Validate manifest
1689
+ from .manifest import validate_manifest
1690
+
1691
+ is_valid, error_msg, error_paths = validate_manifest(manifest_data)
1692
+
1693
+ if not is_valid:
1694
+ error_detail = f"App '{slug}' at {manifest_path}: {error_msg}"
1695
+ if error_paths:
1696
+ error_detail += f" (paths: {', '.join(error_paths)})"
1697
+ validation_errors.append(error_detail)
1698
+ if strict:
1699
+ raise ValueError(
1700
+ f"Manifest validation failed for app '{slug}': {error_msg}"
1701
+ ) from None
1702
+
1703
+ # Validate slug matches manifest slug
1704
+ manifest_slug = manifest_data.get("slug")
1705
+ if manifest_slug and manifest_slug != slug:
1706
+ error_msg = (
1707
+ f"Slug mismatch: config slug '{slug}' does not match "
1708
+ f"manifest slug '{manifest_slug}' in {manifest_path}"
1709
+ )
1710
+ validation_errors.append(error_msg)
1711
+ if strict:
1712
+ raise ValueError(error_msg) from None
1713
+ except FileNotFoundError as e:
1714
+ error_msg = f"Manifest file not found for app '{slug}': {manifest_path}"
1715
+ validation_errors.append(error_msg)
1716
+ if strict:
1717
+ raise ValueError(error_msg) from e
1718
+ except json.JSONDecodeError as e:
1719
+ error_msg = f"Invalid JSON in manifest for app '{slug}' at {manifest_path}: {e}"
1720
+ validation_errors.append(error_msg)
1721
+ if strict:
1722
+ raise ValueError(error_msg) from e
1723
+
1724
+ return validation_errors
1725
+
1726
+ def create_multi_app( # noqa: C901
1582
1727
  self,
1583
1728
  apps: list[dict[str, Any]] | None = None,
1584
1729
  multi_app_manifest: Path | None = None,
1730
+ apps_dir: Path | None = None,
1731
+ path_prefix_template: str | None = None,
1732
+ validate: bool = False,
1733
+ strict: bool = False,
1585
1734
  title: str = "Multi-App API",
1586
1735
  root_path: str = "",
1587
1736
  **fastapi_kwargs: Any,
@@ -1613,6 +1762,15 @@ class MongoDBEngine:
1613
1762
  ]
1614
1763
  }
1615
1764
  }
1765
+ apps_dir: Directory to scan for apps (auto-discovery). If provided and
1766
+ apps is None, will recursively scan for manifest.json files and
1767
+ auto-discover apps. Takes precedence over multi_app_manifest.
1768
+ path_prefix_template: Template for auto-generated path prefixes when using
1769
+ apps_dir. Use {index} for app index and {slug} for app slug.
1770
+ Example: "/app-{index}" or "/{slug}"
1771
+ validate: If True, validate all manifests before mounting (default: False)
1772
+ strict: If True, fail fast on any validation error (default: False).
1773
+ Only used when validate=True.
1616
1774
  title: Title for the parent FastAPI app
1617
1775
  root_path: Root path prefix for all mounted apps (optional)
1618
1776
  **fastapi_kwargs: Additional arguments passed to FastAPI()
@@ -1624,6 +1782,19 @@ class MongoDBEngine:
1624
1782
  ValueError: If configuration is invalid or path prefixes conflict
1625
1783
  RuntimeError: If engine is not initialized
1626
1784
 
1785
+ Features:
1786
+ - Built-in app context helpers: Each mounted app has access to:
1787
+ - request.state.app_base_path: Path prefix (e.g., "/app-1")
1788
+ - request.state.auth_hub_url: Auth hub URL from manifest or env
1789
+ - request.state.app_slug: App slug
1790
+ - request.state.mounted_apps: Dict of all mounted apps with paths
1791
+ - request.state.engine: MongoDBEngine instance
1792
+ - request.state.manifest: App's manifest.json
1793
+ - Unified health check: GET /health aggregates health from all apps
1794
+ - Route introspection: GET /_mdb/routes lists all routes from all apps
1795
+ - OpenAPI aggregation: /docs combines docs from all apps
1796
+ - Per-app docs: /docs/{app_slug} for individual app documentation
1797
+
1627
1798
  Example:
1628
1799
  # Programmatic approach
1629
1800
  engine = MongoDBEngine(mongo_uri=..., db_name=...)
@@ -1646,6 +1817,21 @@ class MongoDBEngine:
1646
1817
  app = engine.create_multi_app(
1647
1818
  multi_app_manifest=Path("./multi_app_manifest.json")
1648
1819
  )
1820
+
1821
+ # Auto-discovery approach
1822
+ app = engine.create_multi_app(
1823
+ apps_dir=Path("./apps"),
1824
+ path_prefix_template="/app-{index}",
1825
+ validate=True,
1826
+ )
1827
+
1828
+ # Access app context in routes
1829
+ @app.get("/my-route")
1830
+ async def my_route(request: Request):
1831
+ base_path = request.state.app_base_path # "/app-1"
1832
+ auth_url = request.state.auth_hub_url # "/auth-hub"
1833
+ slug = request.state.app_slug # "my-app"
1834
+ all_apps = request.state.mounted_apps # Dict of all apps
1649
1835
  """
1650
1836
  import json
1651
1837
 
@@ -1653,6 +1839,14 @@ class MongoDBEngine:
1653
1839
 
1654
1840
  engine = self
1655
1841
 
1842
+ # Auto-discovery: if apps_dir is provided and apps is None, discover apps
1843
+ if apps_dir and apps is None:
1844
+ logger.info(f"Auto-discovering apps from directory: {apps_dir}")
1845
+ apps = self._discover_apps_from_directory(
1846
+ apps_dir=apps_dir,
1847
+ path_prefix_template=path_prefix_template,
1848
+ )
1849
+
1656
1850
  # Load configuration from manifest or apps parameter
1657
1851
  if multi_app_manifest:
1658
1852
  manifest_path = Path(multi_app_manifest)
@@ -1708,12 +1902,26 @@ class MongoDBEngine:
1708
1902
  }
1709
1903
  )
1710
1904
  else:
1711
- raise ValueError("Either 'apps' or 'multi_app_manifest' must be provided")
1905
+ raise ValueError("Either 'apps', 'multi_app_manifest', or 'apps_dir' must be provided")
1712
1906
 
1713
1907
  if not apps:
1714
1908
  raise ValueError("At least one app must be configured")
1715
1909
 
1716
- # Validate path prefixes
1910
+ # Validate manifests if requested
1911
+ if validate:
1912
+ validation_errors = self._validate_manifests(apps, strict)
1913
+ if validation_errors:
1914
+ logger.warning(
1915
+ "Manifest validation found issues:\n"
1916
+ + "\n".join(f" - {e}" for e in validation_errors)
1917
+ )
1918
+ if strict:
1919
+ raise ValueError(
1920
+ "Manifest validation failed (strict mode):\n"
1921
+ + "\n".join(f" - {e}" for e in validation_errors)
1922
+ )
1923
+
1924
+ # Validate path prefixes (enhanced)
1717
1925
  is_valid, errors = self._validate_path_prefixes(apps)
1718
1926
  if not is_valid:
1719
1927
  raise ValueError(
@@ -1734,6 +1942,23 @@ class MongoDBEngine:
1734
1942
  except (FileNotFoundError, json.JSONDecodeError, KeyError) as e:
1735
1943
  logger.warning(f"Could not check auth mode for app '{app_config.get('slug')}': {e}")
1736
1944
 
1945
+ # Validate hooks before creating lifespan (fail fast)
1946
+ for app_config in apps:
1947
+ slug = app_config.get("slug", "unknown")
1948
+ on_startup = app_config.get("on_startup")
1949
+ on_shutdown = app_config.get("on_shutdown")
1950
+
1951
+ if on_startup is not None and not callable(on_startup):
1952
+ raise ValueError(
1953
+ f"on_startup hook for app '{slug}' must be callable, "
1954
+ f"got {type(on_startup).__name__}"
1955
+ )
1956
+ if on_shutdown is not None and not callable(on_shutdown):
1957
+ raise ValueError(
1958
+ f"on_shutdown hook for app '{slug}' must be callable, "
1959
+ f"got {type(on_shutdown).__name__}"
1960
+ )
1961
+
1737
1962
  # State for parent app
1738
1963
  mounted_apps: list[dict[str, Any]] = []
1739
1964
  shared_user_pool_initialized = False
@@ -1776,6 +2001,32 @@ class MongoDBEngine:
1776
2001
  on_shutdown = app_config.get("on_shutdown")
1777
2002
 
1778
2003
  try:
2004
+ # Load manifest for context helpers
2005
+ try:
2006
+ with open(manifest_path) as f:
2007
+ app_manifest_data = json.load(f)
2008
+ except (FileNotFoundError, json.JSONDecodeError) as e:
2009
+ raise ValueError(
2010
+ f"Failed to load manifest for app '{slug}' at {manifest_path}: {e}"
2011
+ ) from e
2012
+
2013
+ # Log app configuration
2014
+ auth_config = app_manifest_data.get("auth", {})
2015
+ auth_mode = auth_config.get("mode", "app")
2016
+ public_routes = auth_config.get("public_routes", [])
2017
+ logger.info(
2018
+ f"Mounting app '{slug}' at '{path_prefix}': "
2019
+ f"auth_mode={auth_mode}, "
2020
+ f"public_routes={len(public_routes)} routes"
2021
+ )
2022
+ if public_routes:
2023
+ logger.debug(f" Public routes for '{slug}': {public_routes}")
2024
+ else:
2025
+ logger.warning(
2026
+ f" App '{slug}' has no public routes configured. "
2027
+ "All routes will require authentication."
2028
+ )
2029
+
1779
2030
  # Create child app as sub-app (shares engine and lifecycle)
1780
2031
  child_app = engine.create_app(
1781
2032
  slug=slug,
@@ -1793,6 +2044,93 @@ class MongoDBEngine:
1793
2044
  child_app.state.audit_log = app.state.audit_log
1794
2045
  logger.debug(f"Shared user_pool with child app '{slug}'")
1795
2046
 
2047
+ # Add middleware for app context helpers
2048
+ from starlette.middleware.base import BaseHTTPMiddleware
2049
+ from starlette.requests import Request
2050
+
2051
+ # Get auth_hub_url from manifest or env
2052
+ auth_hub_url = None
2053
+ if auth_config.get("mode") == "shared":
2054
+ auth_hub_url = auth_config.get("auth_hub_url")
2055
+ if not auth_hub_url:
2056
+ auth_hub_url = os.getenv("AUTH_HUB_URL", "/auth-hub")
2057
+
2058
+ # Store parent app reference and current app info for middleware
2059
+ child_app.state.parent_app = app
2060
+ child_app.state.app_slug = slug
2061
+ child_app.state.app_base_path = path_prefix
2062
+ child_app.state.app_auth_hub_url = auth_hub_url
2063
+ child_app.state.app_manifest = app_manifest_data
2064
+
2065
+ # Create middleware factory to properly capture loop variables
2066
+ def create_app_context_middleware(
2067
+ app_slug: str,
2068
+ app_path_prefix: str,
2069
+ app_auth_hub_url_val: str,
2070
+ app_manifest_data_val: dict[str, Any],
2071
+ ) -> type[BaseHTTPMiddleware]:
2072
+ """Create middleware class with captured variables."""
2073
+
2074
+ class _AppContextMiddleware(BaseHTTPMiddleware):
2075
+ """Middleware that sets app context helpers on request.state."""
2076
+
2077
+ async def dispatch(self, request: Request, call_next):
2078
+ # Get parent app from child app state
2079
+ parent_app = getattr(request.app.state, "parent_app", None)
2080
+
2081
+ # Set app context helpers
2082
+ request.state.app_base_path = getattr(
2083
+ request.app.state,
2084
+ "app_base_path",
2085
+ app_path_prefix,
2086
+ )
2087
+ request.state.auth_hub_url = getattr(
2088
+ request.app.state,
2089
+ "app_auth_hub_url",
2090
+ app_auth_hub_url_val,
2091
+ )
2092
+ request.state.app_slug = getattr(
2093
+ request.app.state, "app_slug", app_slug
2094
+ )
2095
+ request.state.engine = engine
2096
+ request.state.manifest = getattr(
2097
+ request.app.state,
2098
+ "app_manifest",
2099
+ app_manifest_data_val,
2100
+ )
2101
+
2102
+ # Get mounted apps from parent app state
2103
+ if parent_app and hasattr(parent_app.state, "mounted_apps"):
2104
+ mounted_apps_list = parent_app.state.mounted_apps
2105
+ request.state.mounted_apps = {
2106
+ ma["slug"]: {
2107
+ "slug": ma["slug"],
2108
+ "path_prefix": ma.get("path_prefix"),
2109
+ "status": ma.get("status", "unknown"),
2110
+ }
2111
+ for ma in mounted_apps_list
2112
+ }
2113
+ else:
2114
+ # Fallback: create minimal dict with current app
2115
+ request.state.mounted_apps = {
2116
+ app_slug: {
2117
+ "slug": app_slug,
2118
+ "path_prefix": app_path_prefix,
2119
+ "status": "mounted",
2120
+ }
2121
+ }
2122
+
2123
+ response = await call_next(request)
2124
+ return response
2125
+
2126
+ return _AppContextMiddleware
2127
+
2128
+ middleware_class = create_app_context_middleware(
2129
+ slug, path_prefix, auth_hub_url, app_manifest_data
2130
+ )
2131
+ child_app.add_middleware(middleware_class)
2132
+ logger.debug(f"Added AppContextMiddleware to child app '{slug}'")
2133
+
1796
2134
  # Mount child app at path prefix
1797
2135
  app.mount(path_prefix, child_app)
1798
2136
  mounted_apps.append(
@@ -1800,27 +2138,91 @@ class MongoDBEngine:
1800
2138
  "slug": slug,
1801
2139
  "path_prefix": path_prefix,
1802
2140
  "status": "mounted",
2141
+ "manifest": app_manifest_data,
1803
2142
  }
1804
2143
  )
1805
2144
  logger.info(f"Mounted app '{slug}' at path prefix '{path_prefix}'")
1806
2145
 
1807
- except (
1808
- FileNotFoundError,
1809
- json.JSONDecodeError,
1810
- ValueError,
1811
- KeyError,
1812
- RuntimeError,
1813
- ) as e:
1814
- logger.error(f"Failed to mount app '{slug}': {e}", exc_info=True)
2146
+ except FileNotFoundError as e:
2147
+ error_msg = (
2148
+ f"Failed to mount app '{slug}' at {path_prefix}: "
2149
+ f"manifest.json not found at {manifest_path}"
2150
+ )
2151
+ logger.error(error_msg, exc_info=True)
1815
2152
  mounted_apps.append(
1816
2153
  {
1817
2154
  "slug": slug,
1818
2155
  "path_prefix": path_prefix,
1819
2156
  "status": "failed",
1820
- "error": str(e),
2157
+ "error": error_msg,
2158
+ "manifest_path": str(manifest_path),
1821
2159
  }
1822
2160
  )
1823
- # Continue with other apps even if one fails
2161
+ if strict:
2162
+ raise ValueError(error_msg) from e
2163
+ continue
2164
+ except json.JSONDecodeError as e:
2165
+ error_msg = (
2166
+ f"Failed to mount app '{slug}' at {path_prefix}: "
2167
+ f"Invalid JSON in manifest.json at {manifest_path}: {e}"
2168
+ )
2169
+ logger.error(error_msg, exc_info=True)
2170
+ mounted_apps.append(
2171
+ {
2172
+ "slug": slug,
2173
+ "path_prefix": path_prefix,
2174
+ "status": "failed",
2175
+ "error": error_msg,
2176
+ "manifest_path": str(manifest_path),
2177
+ }
2178
+ )
2179
+ if strict:
2180
+ raise ValueError(error_msg) from e
2181
+ continue
2182
+ except ValueError as e:
2183
+ error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2184
+ logger.error(error_msg, exc_info=True)
2185
+ mounted_apps.append(
2186
+ {
2187
+ "slug": slug,
2188
+ "path_prefix": path_prefix,
2189
+ "status": "failed",
2190
+ "error": error_msg,
2191
+ "manifest_path": str(manifest_path),
2192
+ }
2193
+ )
2194
+ if strict:
2195
+ raise ValueError(error_msg) from e
2196
+ continue
2197
+ except (KeyError, RuntimeError) as e:
2198
+ error_msg = f"Failed to mount app '{slug}' at {path_prefix}: {e}"
2199
+ logger.error(error_msg, exc_info=True)
2200
+ mounted_apps.append(
2201
+ {
2202
+ "slug": slug,
2203
+ "path_prefix": path_prefix,
2204
+ "status": "failed",
2205
+ "error": error_msg,
2206
+ "manifest_path": str(manifest_path),
2207
+ }
2208
+ )
2209
+ if strict:
2210
+ raise RuntimeError(error_msg) from e
2211
+ continue
2212
+ except (OSError, PermissionError, ImportError, AttributeError, TypeError) as e:
2213
+ error_msg = f"Unexpected error mounting app '{slug}' at {path_prefix}: {e}"
2214
+ logger.error(error_msg, exc_info=True)
2215
+ mounted_apps.append(
2216
+ {
2217
+ "slug": slug,
2218
+ "path_prefix": path_prefix,
2219
+ "status": "failed",
2220
+ "error": error_msg,
2221
+ "manifest_path": str(manifest_path),
2222
+ }
2223
+ )
2224
+ if strict:
2225
+ raise RuntimeError(error_msg) from e
1824
2226
  continue
1825
2227
 
1826
2228
  # Expose engine and mounted apps info on parent app state
@@ -1828,6 +2230,9 @@ class MongoDBEngine:
1828
2230
  app.state.mounted_apps = mounted_apps
1829
2231
  app.state.is_multi_app = True
1830
2232
 
2233
+ # Store app reference in engine for get_mounted_apps()
2234
+ engine._multi_app_instance = app
2235
+
1831
2236
  yield
1832
2237
 
1833
2238
  # Shutdown is handled by parent app
@@ -1875,45 +2280,323 @@ class MongoDBEngine:
1875
2280
  @parent_app.get("/health")
1876
2281
  async def health_check():
1877
2282
  """Unified health check for all mounted apps."""
2283
+ import time
2284
+
1878
2285
  from ..observability import check_engine_health, check_mongodb_health
1879
2286
 
1880
2287
  # Both are async functions
2288
+ start_time = time.time()
1881
2289
  engine_health = await check_engine_health(engine)
1882
2290
  mongo_health = await check_mongodb_health(engine.mongo_client)
2291
+ engine_response_time = int((time.time() - start_time) * 1000)
1883
2292
 
2293
+ # Check each mounted app's status
1884
2294
  mounted_status = {}
1885
2295
  for mounted_app_info in mounted_apps:
1886
- mounted_status[mounted_app_info["slug"]] = {
1887
- "path_prefix": mounted_app_info["path_prefix"],
1888
- "status": mounted_app_info["status"],
2296
+ app_slug = mounted_app_info["slug"]
2297
+ path_prefix = mounted_app_info["path_prefix"]
2298
+ status = mounted_app_info["status"]
2299
+
2300
+ app_status = {
2301
+ "path_prefix": path_prefix,
2302
+ "status": status,
1889
2303
  }
1890
- if "error" in mounted_app_info:
1891
- mounted_status[mounted_app_info["slug"]]["error"] = mounted_app_info["error"]
1892
2304
 
1893
- overall_status = (
1894
- "healthy"
1895
- if engine_health.status.value == "healthy"
2305
+ if "error" in mounted_app_info:
2306
+ app_status["error"] = mounted_app_info["error"]
2307
+ app_status["status"] = "unhealthy"
2308
+ elif status == "mounted":
2309
+ # App is mounted successfully
2310
+ app_status["status"] = "healthy"
2311
+ # Try to get response time by checking if app has routes
2312
+ try:
2313
+ # Find the mounted app and check its route count
2314
+ for route in parent_app.routes:
2315
+ if hasattr(route, "path") and route.path == path_prefix:
2316
+ if hasattr(route, "app"):
2317
+ mounted_app = route.app
2318
+ route_count = len(mounted_app.routes)
2319
+ app_status["route_count"] = route_count
2320
+ break
2321
+ except (AttributeError, TypeError, KeyError):
2322
+ pass
2323
+
2324
+ mounted_status[app_slug] = app_status
2325
+
2326
+ # Determine overall status
2327
+ all_healthy = (
2328
+ engine_health.status.value == "healthy"
1896
2329
  and mongo_health.status.value == "healthy"
1897
- else "unhealthy"
2330
+ and all(
2331
+ app_info.get("status") in ("healthy", "mounted")
2332
+ for app_info in mounted_status.values()
2333
+ )
1898
2334
  )
1899
2335
 
2336
+ overall_status = "healthy" if all_healthy else "unhealthy"
2337
+
1900
2338
  return {
1901
2339
  "status": overall_status,
1902
2340
  "engine": {
1903
2341
  "status": engine_health.status.value,
1904
2342
  "message": engine_health.message,
2343
+ "response_time_ms": engine_response_time,
1905
2344
  },
1906
2345
  "mongodb": {
1907
2346
  "status": mongo_health.status.value,
1908
2347
  "message": mongo_health.message,
1909
2348
  },
1910
- "mounted_apps": mounted_status,
2349
+ "apps": mounted_status,
2350
+ }
2351
+
2352
+ # Add route introspection endpoint
2353
+ @parent_app.get("/_mdb/routes")
2354
+ async def list_routes():
2355
+ """List all routes from all mounted apps."""
2356
+ routes_info = {
2357
+ "parent_app": {
2358
+ "routes": [],
2359
+ },
2360
+ "mounted_apps": {},
1911
2361
  }
1912
2362
 
2363
+ # Get parent app routes
2364
+ for route in parent_app.routes:
2365
+ route_info = {
2366
+ "path": getattr(route, "path", str(route)),
2367
+ "methods": list(getattr(route, "methods", set())),
2368
+ "name": getattr(route, "name", None),
2369
+ }
2370
+ routes_info["parent_app"]["routes"].append(route_info)
2371
+
2372
+ # Get routes from mounted apps
2373
+ for mounted_app_info in mounted_apps:
2374
+ app_slug = mounted_app_info["slug"]
2375
+ path_prefix = mounted_app_info["path_prefix"]
2376
+ status = mounted_app_info["status"]
2377
+
2378
+ if status != "mounted":
2379
+ routes_info["mounted_apps"][app_slug] = {
2380
+ "path_prefix": path_prefix,
2381
+ "status": status,
2382
+ "routes": [],
2383
+ "error": mounted_app_info.get("error"),
2384
+ }
2385
+ continue
2386
+
2387
+ # Find the mounted app
2388
+ app_routes = []
2389
+ for route in parent_app.routes:
2390
+ # Check if this route belongs to the mounted app
2391
+ # Mounted apps appear as Mount routes
2392
+ if hasattr(route, "path") and route.path == path_prefix:
2393
+ # This is the mount point
2394
+ if hasattr(route, "app"):
2395
+ # Get routes from the mounted app
2396
+ mounted_app = route.app
2397
+ for child_route in mounted_app.routes:
2398
+ route_path = getattr(child_route, "path", str(child_route))
2399
+ # Prepend path prefix
2400
+ full_path = (
2401
+ f"{path_prefix}{route_path}"
2402
+ if route_path != "/"
2403
+ else path_prefix
2404
+ )
2405
+
2406
+ route_info = {
2407
+ "path": full_path,
2408
+ "relative_path": route_path,
2409
+ "methods": list(getattr(child_route, "methods", set())),
2410
+ "name": getattr(child_route, "name", None),
2411
+ }
2412
+ app_routes.append(route_info)
2413
+
2414
+ routes_info["mounted_apps"][app_slug] = {
2415
+ "path_prefix": path_prefix,
2416
+ "status": status,
2417
+ "routes": app_routes,
2418
+ "route_count": len(app_routes),
2419
+ }
2420
+
2421
+ return routes_info
2422
+
2423
+ # Aggregate OpenAPI docs from all mounted apps
2424
+ def custom_openapi():
2425
+ """Generate aggregated OpenAPI schema from all mounted apps."""
2426
+ from fastapi.openapi.utils import get_openapi
2427
+
2428
+ if parent_app.openapi_schema:
2429
+ return parent_app.openapi_schema
2430
+
2431
+ # Get base schema from parent app
2432
+ openapi_schema = get_openapi(
2433
+ title=title,
2434
+ version=fastapi_kwargs.get("version", "1.0.0"),
2435
+ description=fastapi_kwargs.get("description", ""),
2436
+ routes=parent_app.routes,
2437
+ )
2438
+
2439
+ # Aggregate schemas from mounted apps
2440
+ for mounted_app_info in mounted_apps:
2441
+ if mounted_app_info.get("status") != "mounted":
2442
+ continue
2443
+
2444
+ app_slug = mounted_app_info["slug"]
2445
+ path_prefix = mounted_app_info["path_prefix"]
2446
+
2447
+ # Find the mounted app
2448
+ for route in parent_app.routes:
2449
+ if hasattr(route, "path") and route.path == path_prefix:
2450
+ if hasattr(route, "app"):
2451
+ mounted_app = route.app
2452
+ try:
2453
+ # Get OpenAPI schema from mounted app
2454
+ child_schema = get_openapi(
2455
+ title=getattr(mounted_app, "title", app_slug),
2456
+ version=getattr(mounted_app, "version", "1.0.0"),
2457
+ description=getattr(mounted_app, "description", ""),
2458
+ routes=mounted_app.routes,
2459
+ )
2460
+
2461
+ # Merge paths with prefix
2462
+ if "paths" in child_schema:
2463
+ for path, methods in child_schema["paths"].items():
2464
+ # Prepend path prefix
2465
+ prefixed_path = (
2466
+ f"{path_prefix}{path}" if path != "/" else path_prefix
2467
+ )
2468
+ openapi_schema["paths"][prefixed_path] = methods
2469
+
2470
+ # Merge components/schemas
2471
+ if "components" in child_schema:
2472
+ if "components" not in openapi_schema:
2473
+ openapi_schema["components"] = {}
2474
+ if "schemas" in child_schema["components"]:
2475
+ if "schemas" not in openapi_schema["components"]:
2476
+ openapi_schema["components"]["schemas"] = {}
2477
+ openapi_schema["components"]["schemas"].update(
2478
+ child_schema["components"]["schemas"]
2479
+ )
2480
+
2481
+ logger.debug(f"Aggregated OpenAPI schema from app '{app_slug}'")
2482
+ except (AttributeError, TypeError, KeyError, ValueError) as e:
2483
+ logger.warning(
2484
+ f"Failed to aggregate OpenAPI schema from app '{app_slug}': {e}"
2485
+ )
2486
+ break
2487
+
2488
+ parent_app.openapi_schema = openapi_schema
2489
+ return openapi_schema
2490
+
2491
+ parent_app.openapi = custom_openapi
2492
+
2493
+ # Add per-app docs endpoint
2494
+ @parent_app.get("/docs/{app_slug}")
2495
+ async def app_docs(app_slug: str):
2496
+ """Get OpenAPI docs for a specific app."""
2497
+ from fastapi.openapi.docs import get_swagger_ui_html
2498
+
2499
+ # Find the app
2500
+ mounted_app = None
2501
+ path_prefix = None
2502
+ for mounted_app_info in mounted_apps:
2503
+ if mounted_app_info["slug"] == app_slug:
2504
+ path_prefix = mounted_app_info["path_prefix"]
2505
+ # Find the mounted app
2506
+ for route in parent_app.routes:
2507
+ if hasattr(route, "path") and route.path == path_prefix:
2508
+ if hasattr(route, "app"):
2509
+ mounted_app = route.app
2510
+ break
2511
+ break
2512
+
2513
+ if not mounted_app:
2514
+ from fastapi import HTTPException
2515
+
2516
+ raise HTTPException(404, f"App '{app_slug}' not found or not mounted")
2517
+
2518
+ # Generate OpenAPI JSON for this app
2519
+ from fastapi.openapi.utils import get_openapi
2520
+
2521
+ openapi_schema = get_openapi(
2522
+ title=getattr(mounted_app, "title", app_slug),
2523
+ version=getattr(mounted_app, "version", "1.0.0"),
2524
+ description=getattr(mounted_app, "description", ""),
2525
+ routes=mounted_app.routes,
2526
+ )
2527
+
2528
+ # Modify paths to include prefix
2529
+ if "paths" in openapi_schema:
2530
+ new_paths = {}
2531
+ for path, methods in openapi_schema["paths"].items():
2532
+ prefixed_path = f"{path_prefix}{path}" if path != "/" else path_prefix
2533
+ new_paths[prefixed_path] = methods
2534
+ openapi_schema["paths"] = new_paths
2535
+
2536
+ # Return Swagger UI HTML
2537
+ openapi_url = f"/_mdb/openapi/{app_slug}.json"
2538
+
2539
+ # Store schema temporarily for the JSON endpoint
2540
+ if not hasattr(parent_app.state, "app_openapi_schemas"):
2541
+ parent_app.state.app_openapi_schemas = {}
2542
+ parent_app.state.app_openapi_schemas[app_slug] = openapi_schema
2543
+
2544
+ return get_swagger_ui_html(
2545
+ openapi_url=openapi_url,
2546
+ title=f"{app_slug} - API Documentation",
2547
+ )
2548
+
2549
+ @parent_app.get("/_mdb/openapi/{app_slug}.json")
2550
+ async def app_openapi_json(app_slug: str):
2551
+ """Get OpenAPI JSON for a specific app."""
2552
+ from fastapi import HTTPException
2553
+
2554
+ if not hasattr(parent_app.state, "app_openapi_schemas"):
2555
+ raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
2556
+
2557
+ schema = parent_app.state.app_openapi_schemas.get(app_slug)
2558
+ if not schema:
2559
+ raise HTTPException(404, f"OpenAPI schema for '{app_slug}' not found")
2560
+
2561
+ return schema
2562
+
1913
2563
  logger.info(f"Multi-app parent created with {len(apps)} app(s) configured")
1914
2564
 
1915
2565
  return parent_app
1916
2566
 
2567
+ def get_mounted_apps(self, app: Optional["FastAPI"] = None) -> list[dict[str, Any]]:
2568
+ """
2569
+ Get metadata about all mounted apps.
2570
+
2571
+ Args:
2572
+ app: FastAPI app instance (optional, will use engine's tracked app if available)
2573
+
2574
+ Returns:
2575
+ List of dicts with app metadata:
2576
+ - slug: App slug
2577
+ - path_prefix: Path prefix where app is mounted
2578
+ - status: Mount status ("mounted", "failed", etc.)
2579
+ - manifest: App manifest (if available)
2580
+ - error: Error message (if status is "failed")
2581
+
2582
+ Example:
2583
+ mounted_apps = engine.get_mounted_apps(app)
2584
+ for app_info in mounted_apps:
2585
+ print(f"App {app_info['slug']} at {app_info['path_prefix']}")
2586
+ """
2587
+ if app is None:
2588
+ # Try to get from engine state if available
2589
+ if hasattr(self, "_multi_app_instance"):
2590
+ app = self._multi_app_instance
2591
+ else:
2592
+ raise ValueError(
2593
+ "App instance required. Pass app parameter or use "
2594
+ "app.state.mounted_apps directly."
2595
+ )
2596
+
2597
+ mounted_apps = getattr(app.state, "mounted_apps", [])
2598
+ return mounted_apps
2599
+
1917
2600
  async def _initialize_shared_user_pool(
1918
2601
  self,
1919
2602
  app: "FastAPI",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mdb-engine
3
- Version: 0.3.1
3
+ Version: 0.4.1
4
4
  Summary: MongoDB Engine
5
5
  Home-page: https://github.com/ranfysvalle02/mdb-engine
6
6
  Author: Fabian Valle
@@ -1,5 +1,5 @@
1
1
  mdb_engine/README.md,sha256=T3EFGcPopY9LslYW3lxgG3hohWkAOmBNbYG0FDMUJiY,3502
2
- mdb_engine/__init__.py,sha256=hyAHO2lyogYrvCIKKoTLuaReM3g0f7gygVUbg2Iht5k,3163
2
+ mdb_engine/__init__.py,sha256=hYVtAlyPN9kRthzuVQ5A_m-5IIh5Q4LJIyzOzUQtGE8,3172
3
3
  mdb_engine/config.py,sha256=DTAyxfKB8ogyI0v5QR9Y-SJOgXQr_eDBCKxNBSqEyLc,7269
4
4
  mdb_engine/constants.py,sha256=eaotvW57TVOg7rRbLziGrVNoP7adgw_G9iVByHezc_A,7837
5
5
  mdb_engine/dependencies.py,sha256=MJuYQhZ9ZGzXlip1ha5zba9Rvn04HDPWahJFJH81Q2s,14107
@@ -26,7 +26,7 @@ mdb_engine/auth/provider.py,sha256=FOSHn-jp4VYtxvmjnzho5kH6y-xDKWbcKUorYRRl1C4,2
26
26
  mdb_engine/auth/rate_limiter.py,sha256=l3EYZE1Kz9yVfZwNrKq_1AgdD7GXB1WOLSqqGQVSSgA,15808
27
27
  mdb_engine/auth/restrictions.py,sha256=tOyQBO_w0bK9zmTsOPZf9cbvh4oITvpNfSxIXt-XrcU,8824
28
28
  mdb_engine/auth/session_manager.py,sha256=ywWJjTarm-obgJ3zO3s-1cdqEYe0XrozlY00q_yMJ8I,15396
29
- mdb_engine/auth/shared_middleware.py,sha256=ZyGjv3cQ5pLeFBKrW4X2W11e0-ZsuOhJaWvGAQ7I2Kw,31137
29
+ mdb_engine/auth/shared_middleware.py,sha256=nOiswgK8ptx7zUG70YN7LQNhF8PSwwkM_atAO2CAzo4,32100
30
30
  mdb_engine/auth/shared_users.py,sha256=25OBks4VRHaYZW7R61vnplV7wmr7RRpDctSgnej_nxc,26773
31
31
  mdb_engine/auth/token_lifecycle.py,sha256=Q9S1X2Y6W7Ckt5PvyYXswBRh2Tg9DGpyRv_3Xve7VYQ,6708
32
32
  mdb_engine/auth/token_store.py,sha256=-B8j5RH5YEoKsswF4rnMoI51BaxMe4icke3kuehXmcI,9121
@@ -46,7 +46,7 @@ mdb_engine/core/app_registration.py,sha256=7szt2a7aBkpSppjmhdkkPPYMKGKo0MkLKZeEe
46
46
  mdb_engine/core/app_secrets.py,sha256=bo-syg9UUATibNyXEZs-0TTYWG-JaY-2S0yNSGA12n0,10524
47
47
  mdb_engine/core/connection.py,sha256=XnwuPG34pJ7kJGJ84T0mhj1UZ6_CLz_9qZf6NRYGIS8,8346
48
48
  mdb_engine/core/encryption.py,sha256=RZ5LPF5g28E3ZBn6v1IMw_oas7u9YGFtBcEj8lTi9LM,7515
49
- mdb_engine/core/engine.py,sha256=R1mU99Aa3z0P4xlPnofm9H9sZr4CWnsAaa85j-N3iJQ,86857
49
+ mdb_engine/core/engine.py,sha256=EH3vPgdJuNsg8JiIki4Auz1slrM8dA1WXwfOjPeuh8Y,118185
50
50
  mdb_engine/core/index_management.py,sha256=9-r7MIy3JnjQ35sGqsbj8K_I07vAUWtAVgSWC99lJcE,5555
51
51
  mdb_engine/core/manifest.py,sha256=jguhjVPAHMZGxOJcdSGouv9_XiKmxUjDmyjn2yXHCj4,139205
52
52
  mdb_engine/core/ray_integration.py,sha256=vexYOzztscvRYje1xTNmXJbi99oJxCaVJAwKfTNTF_E,13610
@@ -89,9 +89,9 @@ mdb_engine/routing/__init__.py,sha256=reupjHi_RTc2ZBA4AH5XzobAmqy4EQIsfSUcTkFknU
89
89
  mdb_engine/routing/websockets.py,sha256=3X4OjQv_Nln4UmeifJky0gFhMG8A6alR77I8g1iIOLY,29311
90
90
  mdb_engine/utils/__init__.py,sha256=lDxQSGqkV4fVw5TWIk6FA6_eey_ZnEtMY0fir3cpAe8,236
91
91
  mdb_engine/utils/mongo.py,sha256=Oqtv4tQdpiiZzrilGLEYQPo8Vmh8WsTQypxQs8Of53s,3369
92
- mdb_engine-0.3.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
- mdb_engine-0.3.1.dist-info/METADATA,sha256=Zxjb2zcIkgk4-eUJbWv0nDs9OMKpeJuiANzINiQkMAw,15810
94
- mdb_engine-0.3.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
- mdb_engine-0.3.1.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
- mdb_engine-0.3.1.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
- mdb_engine-0.3.1.dist-info/RECORD,,
92
+ mdb_engine-0.4.1.dist-info/licenses/LICENSE,sha256=hIahDEOTzuHCU5J2nd07LWwkLW7Hko4UFO__ffsvB-8,34523
93
+ mdb_engine-0.4.1.dist-info/METADATA,sha256=kyV21MMeAU9NF0MuVLYHWX6k1I-GIG01YZ45iNEZIj4,15810
94
+ mdb_engine-0.4.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
95
+ mdb_engine-0.4.1.dist-info/entry_points.txt,sha256=INCbYdFbBzJalwPwxliEzLmPfR57IvQ7RAXG_pn8cL8,48
96
+ mdb_engine-0.4.1.dist-info/top_level.txt,sha256=PH0UEBwTtgkm2vWvC9He_EOMn7hVn_Wg_Jyc0SmeO8k,11
97
+ mdb_engine-0.4.1.dist-info/RECORD,,