More annotation added to the program

This commit is contained in:
2026-05-30 12:44:44 +08:00
parent b3f29398f0
commit bf223b52ac
22 changed files with 729 additions and 353 deletions
+81 -5
View File
@@ -29,6 +29,8 @@ from routed_layout_preview import create_routed_layout_svg, layout_has_links
from technology_manifest import TechnologyManifestError, read_technology_manifest
# --- Path Configurations ---
# Centralize all filesystem roots used by Flask routes, previews, project
# storage, PDK discovery, icon serving, and temporary export downloads.
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
FRONTEND_DIR = os.path.join(BASE_DIR, '..', 'frontend')
@@ -39,14 +41,17 @@ PDK_PUBLIC_ROOT = os.path.abspath(os.environ.get(
))
EDA_PDK_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs'))
YML_PATH = os.path.join(BASE_DIR, '..', 'mxpic', 'PDKs', 'Silterra', 'directories.yaml')
# Define where your new icons folder is located (adjust if it's placed elsewhere)
# Component/category icons are served from backend/icons for the library panel.
ICONS_DIR = os.path.join(BASE_DIR, 'icons')
#build layout save path
# Saved project YAML, generated previews, and temporary exports live under the
# database folder so each user can have isolated project storage.
DATABASE_ROOT = os.path.abspath(os.path.join(BASE_DIR, '..', 'database'))
EXPORT_ROOT = os.path.abspath(os.path.join(DATABASE_ROOT, '_exports'))
# Flask serves the HTML/CSS/JS frontend and exposes JSON APIs for persistence,
# PDK lookup, preview generation, and GDS export.
app = Flask(__name__, template_folder=FRONTEND_DIR, static_folder=FRONTEND_DIR)
app.secret_key = os.environ.get('MXPIC_SECRET_KEY', 'change_me_for_intranet_deployment')
app.config.update(
@@ -56,6 +61,7 @@ app.config.update(
)
app.json.sort_keys = False
# Initialize the lightweight local database when the server process starts.
database.init_db()
@@ -68,8 +74,10 @@ def no_cache_response(response):
def login_required_json(view_func):
"""Wrap API handlers so unauthenticated requests receive JSON errors."""
@wraps(view_func)
def wrapper(*args, **kwargs):
"""Run the wrapped route only when a user session is present."""
if 'user_id' not in session:
return jsonify({"error": "Authentication required"}), 401
return view_func(*args, **kwargs)
@@ -77,6 +85,7 @@ def login_required_json(view_func):
def request_ip():
"""Extract the best available client IP address for audit logging."""
forwarded_for = request.headers.get('X-Forwarded-For', '')
if forwarded_for:
return forwarded_for.split(',')[0].strip()
@@ -84,6 +93,7 @@ def request_ip():
def record_action(action, project=None, cell=None, detail=None):
"""Write a user action into the audit log using current session context."""
if 'user_id' not in session:
return
if isinstance(detail, (dict, list)):
@@ -112,29 +122,37 @@ def safe_name(value, fallback):
def user_layout_root():
"""Return the current user layout directory under the database root."""
username = safe_name(session.get('username'), 'anonymous')
return os.path.join(DATABASE_ROOT, username, 'layout')
def project_root(project_name):
"""Return the filesystem directory for a named project."""
return os.path.join(user_layout_root(), safe_name(project_name, 'project_1'))
def cell_file_path(project_name, cell_name):
"""Return the YAML file path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.yml")
def cell_svg_path(project_name, cell_name):
"""Return the SVG preview path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.svg")
def cell_routes_path(project_name, cell_name):
"""Return the route sidecar JSON path for a project cell."""
return os.path.join(project_root(project_name), f"{safe_name(cell_name, 'canvas_1')}.routes.yml")
def write_route_points_sidecar(yaml_content, output_path):
"""Extract route points from layout YAML and save them beside the cell."""
layout = yaml.safe_load(yaml_content) or {}
routes = {}
# The sidecar preserves manually edited route control points separately from
# the main YAML file for tooling that wants route-only metadata.
for bundle_name, bundle in (layout.get("bundles") or {}).items():
saved_links = []
for link in bundle.get("links") or []:
@@ -159,34 +177,43 @@ def write_route_points_sidecar(yaml_content, output_path):
def project_gds_path(project_name):
"""Return the generated GDS path for a project."""
return os.path.join(project_root(project_name), f"{safe_name(project_name, 'project_1')}.gds")
def technology_manifest_path_for_project(project_name):
"""Return the stored technology manifest path for a project."""
technology_id = read_project_meta(project_name).get("technology") or ""
if "/" not in technology_id:
return None
foundry, technology = technology_id.split("/", 1)
path = os.path.abspath(os.path.join(EDA_PDK_ROOT, safe_name(foundry, ''), safe_name(technology, ''), "technology.yml"))
# Keep stored project metadata from escaping the local EDA PDK root.
if path.startswith(EDA_PDK_ROOT + os.sep) and os.path.exists(path):
return path
return None
def current_pdk_root():
"""Resolve the active PDK root for the current request session."""
return pdk_root_for_session(session, REPO_ROOT)
def current_pdk_registry():
"""Create a PDK registry configured for the current session scope."""
return PdkRegistry(current_pdk_root(), prefer_full_gds=prefer_full_gds_for_session(session))
def scoped_pdk_root_for_project(project_name):
"""Resolve the correct PDK root for an existing project and session."""
base_root = current_pdk_root()
project = safe_name(project_name, '')
if not project:
return base_root
# When a project has a saved foundry/technology, library browsing is scoped
# to that technology folder; otherwise it falls back to the user's full PDK
# access root.
technology_id = read_project_meta(project).get("technology") or ""
if "/" not in technology_id:
return base_root
@@ -199,15 +226,18 @@ def scoped_pdk_root_for_project(project_name):
def pdk_root_for_request_project():
"""Resolve the PDK root requested by a project-aware API call."""
project = request.args.get('project')
return scoped_pdk_root_for_project(project) if project else current_pdk_root()
def project_meta_path(project_name):
"""Return the metadata JSON path for a project."""
return os.path.join(project_root(project_name), ".project.json")
def read_project_meta(project_name):
"""Read project metadata such as foundry and technology selections."""
path = project_meta_path(project_name)
if not os.path.exists(path):
return {}
@@ -216,25 +246,32 @@ def read_project_meta(project_name):
def write_project_meta(project_name, meta):
"""Persist project metadata to disk."""
os.makedirs(project_root(project_name), exist_ok=True)
with open(project_meta_path(project_name), 'w', encoding='utf-8') as f:
json.dump(meta, f, indent=2)
def ensure_project_path(project_name):
"""Create and return the directory for a project."""
layout_root = os.path.abspath(user_layout_root())
target = os.path.abspath(project_root(project_name))
# All project paths must remain under the authenticated user's layout root.
if target != layout_root and not target.startswith(layout_root + os.sep):
raise ValueError("Invalid project path")
return target
# ... [Keep countSpaces and buildTree exactly as they are] ...
# --- PDK Library Scanning Helpers ---
# These helpers turn the active PDK folder structure into the nested component
# library tree shown in the canvas sidebar.
def findComps(baseDir, path_root=None):
"""Scan component folders, return map of paths -> component info."""
compMap = {}
refDir = baseDir
path_root = os.path.abspath(path_root or baseDir)
# A folder containing a YAML file is treated as a component leaf; scanning
# stops below that leaf so nested assets do not pollute the library tree.
for root, dirs, files in os.walk(baseDir):
ymlFiles = [f for f in files if f.endswith('.yml')]
if ymlFiles:
@@ -260,6 +297,8 @@ def addCompsToTree(compMap):
"""Build a completely fresh tree from scratch and insert component nodes."""
fresh_tree = OrderedDict()
# Convert path tuples collected from disk into the nested object structure
# consumed by the frontend library panel.
for mapKey, compItem in compMap.items():
pathSeg = mapKey[:-1]
compName = compItem['folder']
@@ -280,13 +319,15 @@ def addCompsToTree(compMap):
return fresh_tree
# ... [Keep readCompYaml and Page Routes exactly as they are] ...
# Component metadata lookup helpers used by library/detail API endpoints.
# --- API ROUTES (Library, Components & Icons) ---
@app.route('/api/icon/<category>')
def getIcon(category):
"""Serve the icon corresponding to the component category."""
# Prefer exact category artwork, then fall back to a default icon or a
# transparent 1x1 image to keep the frontend layout stable.
for ext in ('.png', '.svg', '.jpg'):
icon_path = os.path.join(ICONS_DIR, f"{category}{ext}")
if os.path.exists(icon_path):
@@ -304,11 +345,14 @@ def getIcon(category):
)
return Response(transparent_png, mimetype='image/png')
# ... [Keep existing API routes below] ...
# Component metadata helpers sit near the library routes because they share the
# same role-scoped PDK root resolution.
def readCompYaml(compName, comps_root=None):
"""Load YAML from component folder."""
search_root = comps_root or current_pdk_root()
# Component names are resolved by folder basename for compatibility with
# saved canvas component references.
for root, dirs, files in os.walk(search_root):
if os.path.basename(root) == compName:
dirs.clear()
@@ -321,6 +365,7 @@ def readCompYaml(compName, comps_root=None):
def find_component_dir(component_name, comps_root=None):
"""Find the directory containing a named component in the PDK library."""
search_root = comps_root or current_pdk_root()
for root, dirs, files in os.walk(search_root):
if os.path.basename(root) == component_name:
@@ -387,6 +432,7 @@ def logout():
@app.route('/api/health')
def health_check():
"""Expose a small deployment health endpoint."""
return jsonify({"status": "ok", "service": "mxpic_eda"})
@@ -396,6 +442,8 @@ def list_technologies():
"""List technology choices from mxpic/PDKs/<foundry>/<technology>."""
technologies = []
pdks_root = EDA_PDK_ROOT
# Technology choices are built from directory names because each technology
# folder owns its generated technology.yml manifest.
if os.path.isdir(pdks_root):
for foundry in sorted(os.listdir(pdks_root)):
foundry_path = os.path.join(pdks_root, foundry)
@@ -417,6 +465,7 @@ def list_technologies():
@app.route('/api/technologies/<foundry>/<technology>/manifest', methods=['GET'])
@login_required_json
def get_technology_manifest(foundry, technology):
"""Return the routing and layer manifest for a selected technology."""
try:
manifest = read_technology_manifest(
EDA_PDK_ROOT,
@@ -431,6 +480,9 @@ def get_technology_manifest(foundry, technology):
@app.route('/api/profile', methods=['GET', 'PATCH'])
@login_required_json
def account_profile():
"""Return or update profile details for the current user."""
# Keep the frontend occupation selector constrained to known values that are
# meaningful for the account page.
occupations = {'intern', 'senior engineer', 'junior engineer', 'principle engineer'}
user_id = session.get('user_id')
@@ -460,6 +512,7 @@ def account_profile():
@app.route('/api/profile/password', methods=['POST'])
@login_required_json
def change_password():
"""Validate and persist a password change for the current user."""
data = request.get_json(silent=True) or {}
current_password = data.get('current_password') or ''
new_password = data.get('new_password') or ''
@@ -478,6 +531,7 @@ def change_password():
@app.route('/api/logs', methods=['GET', 'POST'])
@login_required_json
def user_logs():
"""Return recent account activity for the current user."""
if request.method == 'POST':
data = request.get_json(silent=True) or {}
action = safe_name(data.get('action'), '')
@@ -520,6 +574,8 @@ def list_projects():
os.makedirs(root, exist_ok=True)
projects = []
# Each project is a folder and each YAML file inside that folder is treated
# as one saved cell/canvas.
for name in sorted(os.listdir(root)):
path = os.path.join(root, name)
if not os.path.isdir(path):
@@ -547,12 +603,15 @@ def list_projects():
@app.route('/api/projects', methods=['POST'])
@login_required_json
def create_project():
"""Create a new project folder and initial cell layout."""
data = request.get_json(silent=True) or {}
requested_name = safe_name(data.get('name'), 'project_1')
technology = data.get('technology') or ''
root = user_layout_root()
os.makedirs(root, exist_ok=True)
# Preserve the user's requested base name, adding a numeric suffix only when
# a project folder already exists.
project_name = requested_name
counter = 1
while os.path.exists(os.path.join(root, project_name)):
@@ -618,6 +677,7 @@ def delete_project(project_name):
@app.route('/api/projects/<project_name>/cells/<cell_name>', methods=['PATCH', 'DELETE'])
@login_required_json
def rename_cell(project_name, cell_name):
"""Rename a project cell and its preview/route sidecar files."""
if request.method == 'DELETE':
cell = safe_name(cell_name, 'canvas_1')
target = os.path.abspath(cell_file_path(project_name, cell))
@@ -653,6 +713,7 @@ def rename_cell(project_name, cell_name):
@app.route('/api/save-layout', methods=['POST'])
@login_required_json
def save_layout():
"""Persist a canvas layout YAML document and refresh its preview assets."""
try:
data = request.get_json()
project = safe_name(data.get('project'), 'project_1')
@@ -670,6 +731,8 @@ def save_layout():
svg_path = None
if create_preview:
svg_path = cell_svg_path(project, cell)
# Routed layouts need the router backend; placement-only layouts can
# use the simpler GDS/SVG preview path.
if layout_has_links(content):
create_routed_layout_svg(
content,
@@ -699,6 +762,7 @@ def save_layout():
@app.route('/api/projects/<project_name>/cells/<cell_name>/layout.svg')
@login_required_json
def get_layout_svg(project_name, cell_name):
"""Serve a saved SVG layout preview for a project cell."""
try:
project_dir = ensure_project_path(project_name)
svg_path = os.path.abspath(cell_svg_path(project_name, cell_name))
@@ -714,9 +778,11 @@ def get_layout_svg(project_name, cell_name):
@app.route('/api/build-gds', methods=['POST'])
@login_required_json
def build_gds():
"""Build project GDS output and return a downloadable export URL."""
data = request.get_json(silent=True) or {}
project = safe_name(data.get('project'), 'project_1')
try:
# Expire old exports before creating a new temporary download folder.
cleanup_expired_exports(EXPORT_ROOT)
project_dir = ensure_project_path(project)
if not os.path.isdir(project_dir):
@@ -751,9 +817,12 @@ def build_gds():
@app.route('/api/exports/<export_id>/<filename>')
@login_required_json
def download_export(export_id, filename):
"""Serve a temporary exported GDS file by export id."""
export_id = safe_name(export_id, '')
safe_filename = safe_name(filename, 'layout.gds')
export_dir = os.path.abspath(os.path.join(EXPORT_ROOT, export_id))
# Export downloads are confined to the temporary export root and removed
# after the response closes.
if not export_id or not export_dir.startswith(EXPORT_ROOT + os.sep):
return jsonify({"error": "Invalid export path"}), 400
if not safe_filename.lower().endswith('.gds'):
@@ -769,6 +838,7 @@ def download_export(export_id, filename):
@app.route('/api/projects/<project_name>/gds/<filename>')
@login_required_json
def get_project_gds(project_name, filename):
"""Serve the latest generated GDS file for a project."""
try:
project_dir = ensure_project_path(project_name)
safe_filename = safe_name(filename, f"{safe_name(project_name, 'project_1')}.gds")
@@ -792,6 +862,8 @@ def getLib():
"""Get library structure."""
comps_root = pdk_root_for_request_project()
fresh_tree = {}
# The library tree is rebuilt on request so project technology changes and
# role-scoped PDK roots are reflected immediately.
if os.path.isdir(comps_root):
compMap = findComps(comps_root, current_pdk_root())
fresh_tree = addCompsToTree(compMap)
@@ -814,6 +886,8 @@ def getCompImg(component_name):
"""Return first image in component folder."""
root, files = find_component_dir(component_name, pdk_root_for_request_project())
if root:
# Use the first common image asset in the component folder as its
# preview thumbnail.
for ext in ('.png', '.jpg', '.jpeg', '.svg'):
for f in files:
if f.lower().endswith(ext):
@@ -821,6 +895,8 @@ def getCompImg(component_name):
return jsonify({"error": "No image found"}), 404
if __name__ == '__main__':
# Allow deployment scripts to choose host, port, and debug behavior through
# environment variables.
host = os.environ.get('MXPIC_HOST', '0.0.0.0')
port = int(os.environ.get('MXPIC_PORT', '3000'))
debug = os.environ.get('MXPIC_DEBUG', '0').lower() in {'1', 'true', 'yes'}