fix(metadata): sync project metadata endpoints
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
from fastapi import APIRouter, Depends, HTTPException, status, Query, Path
|
||||||
|
import psycopg
|
||||||
from psycopg import AsyncConnection
|
from psycopg import AsyncConnection
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
@@ -58,6 +59,7 @@ async def get_project_metadata(
|
|||||||
code=project.code,
|
code=project.code,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role=ctx.project_role,
|
project_role=ctx.project_role,
|
||||||
geoserver=geoserver_payload,
|
geoserver=geoserver_payload,
|
||||||
@@ -110,7 +112,23 @@ async def project_db_health(
|
|||||||
|
|
||||||
检查PostgreSQL和TimescaleDB数据库的连接状态
|
检查PostgreSQL和TimescaleDB数据库的连接状态
|
||||||
"""
|
"""
|
||||||
|
try:
|
||||||
await pg_session.execute(text("SELECT 1"))
|
await pg_session.execute(text("SELECT 1"))
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
logger.error("Project PostgreSQL health check failed", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project PostgreSQL health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
try:
|
||||||
async with ts_conn.cursor() as cur:
|
async with ts_conn.cursor() as cur:
|
||||||
await cur.execute("SELECT 1")
|
await cur.execute("SELECT 1")
|
||||||
|
except psycopg.Error as exc:
|
||||||
|
logger.error("Project TimescaleDB health check failed", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail=f"Project TimescaleDB health check failed: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
return {"postgres": "ok", "timescale": "ok"}
|
return {"postgres": "ok", "timescale": "ok"}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import json
|
import json
|
||||||
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body
|
from fastapi import APIRouter, Request, HTTPException, Query, Path, Body, Depends
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||||
|
from app.auth.project_dependencies import get_metadata_repository
|
||||||
|
from app.domain.schemas.metadata import ProjectMetaResponse, GeoServerConfigResponse
|
||||||
import app.services.project_info as project_info
|
import app.services.project_info as project_info
|
||||||
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
|
from app.infra.db.postgresql.database import get_database_instance as get_pg_db
|
||||||
from app.infra.db.timescaledb.database import get_database_instance as get_ts_db
|
from app.infra.db.timescaledb.database import get_database_instance as get_ts_db
|
||||||
@@ -39,6 +42,42 @@ inpDir = "data/" # Assuming data directory exists or is defined somewhere.
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
lockedPrjs: Dict[str, str] = {}
|
lockedPrjs: Dict[str, str] = {}
|
||||||
|
|
||||||
|
@router.get("/project_info/", summary="获取项目信息", description="从数据库获取项目的详细信息,包括地图范围等。", response_model=ProjectMetaResponse)
|
||||||
|
async def get_project_info_endpoint(
|
||||||
|
network: str = Query(..., description="管网名称(或项目代码)"),
|
||||||
|
metadata_repo: MetadataRepository = Depends(get_metadata_repository),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取项目信息
|
||||||
|
|
||||||
|
- **network**: 管网名称(或项目代码)
|
||||||
|
"""
|
||||||
|
project_detail = await metadata_repo.get_project_detail_by_code(network)
|
||||||
|
if not project_detail:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Project {network} not found")
|
||||||
|
|
||||||
|
geoserver_payload = None
|
||||||
|
if project_detail.geoserver:
|
||||||
|
geoserver_payload = GeoServerConfigResponse(
|
||||||
|
gs_base_url=project_detail.geoserver.gs_base_url,
|
||||||
|
gs_admin_user=project_detail.geoserver.gs_admin_user,
|
||||||
|
gs_datastore_name=project_detail.geoserver.gs_datastore_name,
|
||||||
|
default_extent=project_detail.geoserver.default_extent,
|
||||||
|
srid=project_detail.geoserver.srid,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ProjectMetaResponse(
|
||||||
|
project_id=project_detail.project_id,
|
||||||
|
name=project_detail.name,
|
||||||
|
code=project_detail.code,
|
||||||
|
description=project_detail.description,
|
||||||
|
gs_workspace=project_detail.gs_workspace,
|
||||||
|
map_extent=project_detail.map_extent,
|
||||||
|
status=project_detail.status,
|
||||||
|
project_role="viewer",
|
||||||
|
geoserver=geoserver_payload,
|
||||||
|
)
|
||||||
|
|
||||||
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
|
@router.get("/listprojects/", summary="获取项目列表", description="获取服务器上所有可用的供水管网项目名称列表。")
|
||||||
async def list_projects_endpoint() -> list[str]:
|
async def list_projects_endpoint() -> list[str]:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
|
|
||||||
class GeoServerConfigResponse(BaseModel):
|
class GeoServerConfigResponse(BaseModel):
|
||||||
gs_base_url: Optional[str]
|
gs_base_url: Optional[str] = None
|
||||||
gs_admin_user: Optional[str]
|
gs_admin_user: Optional[str] = None
|
||||||
gs_datastore_name: str
|
gs_datastore_name: str
|
||||||
default_extent: Optional[dict]
|
default_extent: Optional[dict] = None
|
||||||
srid: int
|
srid: int
|
||||||
|
|
||||||
|
|
||||||
@@ -16,18 +16,19 @@ class ProjectMetaResponse(BaseModel):
|
|||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
description: Optional[str]
|
description: Optional[str] = None
|
||||||
gs_workspace: str
|
gs_workspace: str
|
||||||
|
map_extent: Optional[dict] = None
|
||||||
status: str
|
status: str
|
||||||
project_role: str
|
project_role: str
|
||||||
geoserver: Optional[GeoServerConfigResponse]
|
geoserver: Optional[GeoServerConfigResponse] = None
|
||||||
|
|
||||||
|
|
||||||
class ProjectSummaryResponse(BaseModel):
|
class ProjectSummaryResponse(BaseModel):
|
||||||
project_id: UUID
|
project_id: UUID
|
||||||
name: str
|
name: str
|
||||||
code: str
|
code: str
|
||||||
description: Optional[str]
|
description: Optional[str] = None
|
||||||
gs_workspace: str
|
gs_workspace: str
|
||||||
status: str
|
status: str
|
||||||
project_role: str
|
project_role: str
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Project(Base):
|
|||||||
code: Mapped[str] = mapped_column(String(50), unique=True)
|
code: Mapped[str] = mapped_column(String(50), unique=True)
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
gs_workspace: Mapped[str] = mapped_column(String(100), unique=True)
|
||||||
|
map_extent: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(20), default="active")
|
status: Mapped[str] = mapped_column(String(20), default="active")
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
DateTime(timezone=True), default=datetime.utcnow
|
DateTime(timezone=True), default=datetime.utcnow
|
||||||
|
|||||||
@@ -61,10 +61,23 @@ class ProjectSummary:
|
|||||||
code: str
|
code: str
|
||||||
description: Optional[str]
|
description: Optional[str]
|
||||||
gs_workspace: str
|
gs_workspace: str
|
||||||
|
map_extent: Optional[dict]
|
||||||
status: str
|
status: str
|
||||||
project_role: str
|
project_role: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectDetail:
|
||||||
|
project_id: UUID
|
||||||
|
name: str
|
||||||
|
code: str
|
||||||
|
description: Optional[str]
|
||||||
|
gs_workspace: str
|
||||||
|
map_extent: Optional[dict]
|
||||||
|
status: str
|
||||||
|
geoserver: Optional[ProjectGeoServerInfo]
|
||||||
|
|
||||||
|
|
||||||
class MetadataRepository:
|
class MetadataRepository:
|
||||||
"""元数据访问层(system_hub)"""
|
"""元数据访问层(system_hub)"""
|
||||||
|
|
||||||
@@ -89,6 +102,30 @@ class MetadataRepository:
|
|||||||
)
|
)
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_project_by_code(self, code: str) -> Optional[models.Project]:
|
||||||
|
result = await self.session.execute(
|
||||||
|
select(models.Project).where(models.Project.code == code)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_project_detail_by_code(self, code: str) -> Optional[ProjectDetail]:
|
||||||
|
project = await self.get_project_by_code(code)
|
||||||
|
if not project:
|
||||||
|
return None
|
||||||
|
|
||||||
|
geoserver = await self.get_geoserver_config(project.id)
|
||||||
|
|
||||||
|
return ProjectDetail(
|
||||||
|
project_id=project.id,
|
||||||
|
name=project.name,
|
||||||
|
code=project.code,
|
||||||
|
description=project.description,
|
||||||
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
|
status=project.status,
|
||||||
|
geoserver=geoserver,
|
||||||
|
)
|
||||||
|
|
||||||
async def get_membership_role(
|
async def get_membership_role(
|
||||||
self, project_id: UUID, user_id: UUID
|
self, project_id: UUID, user_id: UUID
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
@@ -179,6 +216,7 @@ class MetadataRepository:
|
|||||||
code=project.code,
|
code=project.code,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role=role,
|
project_role=role,
|
||||||
)
|
)
|
||||||
@@ -196,6 +234,7 @@ class MetadataRepository:
|
|||||||
code=project.code,
|
code=project.code,
|
||||||
description=project.description,
|
description=project.description,
|
||||||
gs_workspace=project.gs_workspace,
|
gs_workspace=project.gs_workspace,
|
||||||
|
map_extent=project.map_extent,
|
||||||
status=project.status,
|
status=project.status,
|
||||||
project_role="owner",
|
project_role="owner",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
|
from tests.conftest import install_stub, load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_meta_module(monkeypatch):
|
||||||
|
install_stub(monkeypatch, "app.auth", package=True)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.project_dependencies",
|
||||||
|
{
|
||||||
|
"ProjectContext": object,
|
||||||
|
"get_project_context": lambda: None,
|
||||||
|
"get_project_pg_session": lambda: None,
|
||||||
|
"get_project_timescale_connection": lambda: None,
|
||||||
|
"get_metadata_repository": lambda: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.metadata_dependencies",
|
||||||
|
{"get_current_metadata_user": lambda: None},
|
||||||
|
)
|
||||||
|
return load_module_from_path(
|
||||||
|
"tests_meta_endpoints_module",
|
||||||
|
"app/api/v1/endpoints/meta.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_meta_project_returns_map_extent(monkeypatch):
|
||||||
|
module = _load_meta_module(monkeypatch)
|
||||||
|
project_id = uuid4()
|
||||||
|
repo = SimpleNamespace(
|
||||||
|
get_project_by_id=lambda _project_id: None,
|
||||||
|
get_geoserver_config=lambda _project_id: None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_project_by_id(_project_id):
|
||||||
|
return SimpleNamespace(
|
||||||
|
id=project_id,
|
||||||
|
name="Demo Project",
|
||||||
|
code="demo",
|
||||||
|
description="desc",
|
||||||
|
gs_workspace="workspace",
|
||||||
|
map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
|
||||||
|
status="active",
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_geoserver_config(_project_id):
|
||||||
|
return None
|
||||||
|
|
||||||
|
repo.get_project_by_id = get_project_by_id
|
||||||
|
repo.get_geoserver_config = get_geoserver_config
|
||||||
|
|
||||||
|
response = await module.get_project_metadata(
|
||||||
|
ctx=SimpleNamespace(
|
||||||
|
project_id=project_id,
|
||||||
|
project_role="editor",
|
||||||
|
),
|
||||||
|
metadata_repo=repo,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.map_extent == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
|
||||||
|
assert response.project_id == project_id
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_meta_db_health_returns_503_for_postgres_errors(monkeypatch):
|
||||||
|
module = _load_meta_module(monkeypatch)
|
||||||
|
|
||||||
|
class BrokenSession:
|
||||||
|
async def execute(self, _query):
|
||||||
|
raise SQLAlchemyError("pg unavailable")
|
||||||
|
|
||||||
|
class DummyTimescaleConnection:
|
||||||
|
def cursor(self):
|
||||||
|
raise AssertionError("timescale should not be queried after postgres failure")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await module.project_db_health(
|
||||||
|
pg_session=BrokenSession(),
|
||||||
|
ts_conn=DummyTimescaleConnection(),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 503
|
||||||
|
assert exc_info.value.detail == "Project PostgreSQL health check failed: pg unavailable"
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import AsyncMock
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from tests.conftest import install_stub, load_module_from_path
|
||||||
|
|
||||||
|
|
||||||
|
class DummyChangeSet:
|
||||||
|
def __init__(self, operations=None):
|
||||||
|
if operations is None:
|
||||||
|
self.operations = []
|
||||||
|
elif isinstance(operations, dict):
|
||||||
|
self.operations = [operations]
|
||||||
|
else:
|
||||||
|
self.operations = operations
|
||||||
|
|
||||||
|
|
||||||
|
def _load_project_module(monkeypatch):
|
||||||
|
install_stub(monkeypatch, "app.services", package=True)
|
||||||
|
install_stub(monkeypatch, "app.services.project_info", {})
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.services.tjnetwork",
|
||||||
|
{
|
||||||
|
"ChangeSet": DummyChangeSet,
|
||||||
|
"list_project": lambda: ["demo"],
|
||||||
|
"have_project": lambda network: network == "demo",
|
||||||
|
"create_project": lambda network: None,
|
||||||
|
"delete_project": lambda network: None,
|
||||||
|
"is_project_open": lambda network: False,
|
||||||
|
"open_project": lambda network: None,
|
||||||
|
"close_project": lambda network: None,
|
||||||
|
"copy_project": lambda source, target: None,
|
||||||
|
"import_inp": lambda network, cs: {"ok": True},
|
||||||
|
"export_inp": lambda network, version: DummyChangeSet({"kind": "export"}),
|
||||||
|
"read_inp": lambda network, inp: True,
|
||||||
|
"dump_inp": lambda network, inp: True,
|
||||||
|
"get_all_vertices": lambda network: [],
|
||||||
|
"get_all_scada_elements": lambda network: [],
|
||||||
|
"get_all_district_metering_areas": lambda network: [],
|
||||||
|
"get_all_service_areas": lambda network: [],
|
||||||
|
"get_all_virtual_districts": lambda network: [],
|
||||||
|
"get_extension_data": lambda network, key: None,
|
||||||
|
"convert_inp_v3_to_v2": lambda inp: DummyChangeSet({"inp": inp}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.auth.project_dependencies",
|
||||||
|
{"get_metadata_repository": lambda: None},
|
||||||
|
)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.infra.db.postgresql.database",
|
||||||
|
{"get_database_instance": lambda network: None},
|
||||||
|
)
|
||||||
|
install_stub(
|
||||||
|
monkeypatch,
|
||||||
|
"app.infra.db.timescaledb.database",
|
||||||
|
{"get_database_instance": lambda network: None},
|
||||||
|
)
|
||||||
|
return load_module_from_path(
|
||||||
|
"tests_project_endpoints_module",
|
||||||
|
"app/api/v1/endpoints/project.py",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_project_info_returns_404_when_missing(monkeypatch):
|
||||||
|
module = _load_project_module(monkeypatch)
|
||||||
|
repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=None))
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc_info:
|
||||||
|
await module.get_project_info_endpoint(network="missing", metadata_repo=repo)
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 404
|
||||||
|
assert exc_info.value.detail == "Project missing not found"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.anyio
|
||||||
|
async def test_project_info_returns_geoserver_payload(monkeypatch):
|
||||||
|
module = _load_project_module(monkeypatch)
|
||||||
|
detail = SimpleNamespace(
|
||||||
|
project_id=uuid4(),
|
||||||
|
name="Demo Project",
|
||||||
|
code="demo",
|
||||||
|
description="desc",
|
||||||
|
gs_workspace="ws",
|
||||||
|
map_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
|
||||||
|
status="active",
|
||||||
|
geoserver=SimpleNamespace(
|
||||||
|
gs_base_url="http://gs",
|
||||||
|
gs_admin_user="admin",
|
||||||
|
gs_datastore_name="store",
|
||||||
|
default_extent={"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4},
|
||||||
|
srid=4326,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repo = SimpleNamespace(get_project_detail_by_code=AsyncMock(return_value=detail))
|
||||||
|
|
||||||
|
payload = await module.get_project_info_endpoint(network="demo", metadata_repo=repo)
|
||||||
|
|
||||||
|
assert payload.code == "demo"
|
||||||
|
assert payload.map_extent == {"xmin": 1, "ymin": 2, "xmax": 3, "ymax": 4}
|
||||||
|
assert payload.geoserver.gs_base_url == "http://gs"
|
||||||
+49
-2
@@ -1,9 +1,16 @@
|
|||||||
import pytest
|
import importlib
|
||||||
|
import importlib.util
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
import types
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
|
||||||
# 自动添加项目根目录到路径(处理项目结构)
|
# 自动添加项目根目录到路径(处理项目结构)
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
def run_this_test(test_file):
|
def run_this_test(test_file):
|
||||||
@@ -12,3 +19,43 @@ def run_this_test(test_file):
|
|||||||
test_name = os.path.splitext(os.path.basename(test_file))[0]
|
test_name = os.path.splitext(os.path.basename(test_file))[0]
|
||||||
# 使用pytest运行(自动处理导入)
|
# 使用pytest运行(自动处理导入)
|
||||||
pytest.main([test_file, "-v"])
|
pytest.main([test_file, "-v"])
|
||||||
|
|
||||||
|
|
||||||
|
def build_test_app(router, prefix: str = "") -> FastAPI:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router, prefix=prefix)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def load_module_from_path(module_name: str, relative_path: str):
|
||||||
|
module_path = PROJECT_ROOT / relative_path
|
||||||
|
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
assert spec and spec.loader
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
|
||||||
|
def install_stub(monkeypatch, name: str, attrs: dict | None = None, package: bool = False):
|
||||||
|
module = types.ModuleType(name)
|
||||||
|
if package:
|
||||||
|
module.__path__ = []
|
||||||
|
if attrs:
|
||||||
|
for key, value in attrs.items():
|
||||||
|
setattr(module, key, value)
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, name, module)
|
||||||
|
|
||||||
|
parent_name, _, child_name = name.rpartition(".")
|
||||||
|
if parent_name:
|
||||||
|
parent = sys.modules.get(parent_name)
|
||||||
|
if parent is None:
|
||||||
|
try:
|
||||||
|
parent = importlib.import_module(parent_name)
|
||||||
|
except Exception:
|
||||||
|
parent = types.ModuleType(parent_name)
|
||||||
|
parent.__path__ = []
|
||||||
|
monkeypatch.setitem(sys.modules, parent_name, parent)
|
||||||
|
setattr(parent, child_name, module)
|
||||||
|
|
||||||
|
return module
|
||||||
|
|||||||
Reference in New Issue
Block a user