Python编写的yaml编辑器
效果预览


代码
代码采用AI辅助编写
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
树形结构 YAML 编辑器 (修复版)
支持可视化编辑 YAML 文件的层级结构
"""
import tkinter as tk
from tkinter import ttk, filedialog, messagebox
import yaml
import json
import os
from typing import Any, Dict, List, Optional
class TreeYAMLEditor:
"""基于树形结构的 YAML 编辑器"""
def __init__(self, root: tk.Tk):
self.root = root
self.root.title("YAML Editor")
self.is_modified: bool = False
self.yaml_data: Any = {} # ✅ 修复:初始化数据容器
self.current_file: Optional[str] = None # ✅ 修复:初始化文件路径
self.tree_items: Dict[str, tuple] = {} # item_id -> (key, value, path)
self._create_toolbar()
self._create_main_area()
self._create_statusbar()
self.root.protocol("WM_DELETE_WINDOW", self._on_close)
def _create_toolbar(self):
toolbar = ttk.Frame(self.root)
toolbar.pack(side=tk.TOP, fill=tk.X, padx=5, pady=5)
buttons_info = [
("新建", self.new_file), ("打开", self.open_file), ("保存", self.save_file),
("separator", None), ("刷新", self.reload_file), ("separator", None),
("添加", self.add_node), ("编辑值", self.edit_value), ("删除", self.delete_node),
("separator", None), ("JSON", self.show_json_preview), ("校验", self.validate_yaml),
]
for text, cmd in buttons_info:
if text == "separator":
ttk.Separator(toolbar, orient=tk.VERTICAL).pack(side=tk.LEFT, padx=8)
else:
btn = ttk.Button(toolbar, text=text, command=cmd)
btn.pack(side=tk.LEFT, padx=2)
def _create_main_area(self):
paned = ttk.PanedWindow(self.root, orient=tk.HORIZONTAL)
paned.pack(fill=tk.BOTH, expand=True, padx=5, pady=5)
left_frame = ttk.LabelFrame(paned, text="YAML 结构树", padding=5)
paned.add(left_frame, weight=2)
tree_toolbar = ttk.Frame(left_frame)
tree_toolbar.pack(fill=tk.X, pady=(0, 5))
ttk.Button(tree_toolbar, text="展开", command=self.expand_all).pack(side=tk.LEFT, padx=2)
ttk.Button(tree_toolbar, text="折叠", command=self.collapse_all).pack(side=tk.LEFT, padx=2)
self.search_var = tk.StringVar()
ttk.Entry(tree_toolbar, textvariable=self.search_var, width=12).pack(side=tk.RIGHT, padx=5)
ttk.Button(tree_toolbar, text="搜索", command=self.search_node).pack(side=tk.RIGHT, padx=2)
tree_frame = ttk.Frame(left_frame)
tree_frame.pack(fill=tk.BOTH, expand=True)
vsb = ttk.Scrollbar(tree_frame, orient=tk.VERTICAL)
hsb = ttk.Scrollbar(tree_frame, orient=tk.HORIZONTAL)
columns = ("value", "type")
self.tree = ttk.Treeview(tree_frame, columns=columns, yscrollcommand=vsb.set,
xscrollcommand=hsb.set, selectmode="extended", show=["tree"])
self.tree.column("#0", width=300, minwidth=200)
self.tree.column("value", width=200, minwidth=100)
self.tree.column("type", width=100, minwidth=80)
self.tree.heading("#0", text="键")
self.tree.heading("value", text="值")
self.tree.heading("type", text="类型")
vsb.config(command=self.tree.yview)
hsb.config(command=self.tree.xview)
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
vsb.pack(side=tk.RIGHT, fill=tk.Y)
hsb.pack(side=tk.BOTTOM, fill=tk.X)
self.tree.bind("<Double-Button-1>", lambda e: self.edit_value())
self.tree.bind("<Return>", lambda e: self.edit_value())
self.tree.bind("<F2>", lambda e: self.edit_value())
self.tree.bind("<Delete>", lambda e: self.delete_node())
right_frame = ttk.LabelFrame(paned, text="节点编辑", padding=10)
paned.add(right_frame, weight=1)
ttk.Label(right_frame, text="当前路径:", font=("Arial", 10, "bold")).pack(anchor=tk.W)
self.path_label = ttk.Label(right_frame, text="/", background="#e8f4e8",
relief=tk.SUNKEN, font=("Consolas", 10), anchor=tk.W, padding=5)
self.path_label.pack(fill=tk.X, pady=(0, 10))
ttk.Label(right_frame, text="节点信息:", font=("Arial", 10, "bold")).pack(anchor=tk.W)
info_frame = ttk.Frame(right_frame)
info_frame.pack(fill=tk.X, pady=(0, 10))
ttk.Label(info_frame, text="键名:").grid(row=0, column=0, sticky=tk.W, padx=5)
self.key_var = tk.StringVar()
ttk.Entry(info_frame, textvariable=self.key_var, width=20).grid(row=0, column=1, sticky=tk.EW, padx=5)
ttk.Label(info_frame, text="类型:").grid(row=1, column=0, sticky=tk.W, padx=5, pady=5)
self.type_label = ttk.Label(info_frame, text="无", background="#f0f0f0", padding=3)
self.type_label.grid(row=1, column=1, sticky=tk.W, padx=5, pady=5)
info_frame.columnconfigure(1, weight=1)
ttk.Label(right_frame, text="值:", font=("Arial", 10, "bold")).pack(anchor=tk.W)
type_frame = ttk.Frame(right_frame)
type_frame.pack(fill=tk.X, pady=(0, 5))
self.value_type_var = tk.StringVar(value="str")
for val_type, label in [("str", "文本"), ("int", "整数"),
("float", "小数"), ("bool", "布尔"), ("null", "空值")]:
ttk.Radiobutton(type_frame, text=label, variable=self.value_type_var, value=val_type).pack(side=tk.LEFT, padx=2)
self.value_text = tk.Text(right_frame, font=("Consolas", 11), wrap=tk.WORD, height=8,
background="#ffffff", foreground="#333333", insertbackground="#0066cc", relief=tk.GROOVE, borderwidth=1)
self.value_text.pack(fill=tk.BOTH, expand=True, pady=(0, 5))
btn_frame = ttk.Frame(right_frame)
btn_frame.pack(fill=tk.X, pady=(10, 0))
ttk.Button(btn_frame, text="应用", command=self.apply_changes).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="重置", command=self.reset_value).pack(side=tk.LEFT, padx=2)
ttk.Button(btn_frame, text="添加", command=self.add_node).pack(side=tk.RIGHT, padx=2)
preview_frame = ttk.LabelFrame(right_frame, text="JSON 预览", padding=5)
preview_frame.pack(fill=tk.BOTH, expand=True, pady=(15, 0))
self.json_text = tk.Text(preview_frame, font=("Consolas", 9), wrap=tk.WORD, height=6, background="#f8f8f8", state=tk.DISABLED)
self.json_text.pack(fill=tk.BOTH, expand=True)
self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
def _create_statusbar(self):
status_frame = ttk.Frame(self.root)
status_frame.pack(side=tk.BOTTOM, fill=tk.X)
self.status_label = ttk.Label(status_frame, text="就绪", relief=tk.SUNKEN, anchor=tk.W)
self.status_label.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.info_label = ttk.Label(status_frame, text="", relief=tk.SUNKEN, anchor=tk.E)
self.info_label.pack(side=tk.RIGHT)
def load_yaml_to_tree(self, data):
self._clear_tree()
if data is None:
self.yaml_data = {}
return
self.yaml_data = data
if isinstance(data, (dict, list)):
self._build_tree(data, parent="")
else:
self.tree.insert("", tk.END, text=" root", values=[str(data), self._get_type_name(data)])
self.tree_items[""] = ("root", data, "")
def _clear_tree(self):
for item in self.tree.get_children():
self.tree.delete(item)
self.tree_items.clear()
# ✅ 修复:移除错误的 key 参数,统一使用 current_path 追踪完整路径
def _build_tree(self, data, parent="", current_path=""):
if isinstance(data, dict):
for k, v in data.items():
new_path = f"{current_path}/{k}" if current_path else k
if isinstance(v, dict):
display_value, node_type = "{...}", "dict"
elif isinstance(v, list):
display_value, node_type = f"[{len(v)}]", "list"
else:
display_value = str(v) if v is not None else "null"
node_type = self._get_type_name(v)
item_id = self.tree.insert(parent, tk.END, text=f" {k}", values=[display_value, node_type])
self.tree_items[item_id] = (k, v, new_path)
if isinstance(v, (dict, list)):
self._build_tree(v, item_id, new_path)
elif isinstance(data, list):
for i, v in enumerate(data):
new_path = f"{current_path}[{i}]" if current_path else f"[{i}]"
if isinstance(v, dict):
display_value, node_type = "{...}", "dict"
elif isinstance(v, list):
display_value, node_type = f"[{len(v)}]", "list"
else:
display_value = str(v) if v is not None else "null"
node_type = self._get_type_name(v)
item_id = self.tree.insert(parent, tk.END, text=f" [{i}]", values=[display_value, node_type])
self.tree_items[item_id] = (f"[{i}]", v, new_path)
if isinstance(v, (dict, list)):
self._build_tree(v, item_id, new_path)
def _get_type_name(self, value: Any) -> str:
if value is None: return "null"
if isinstance(value, bool): return "bool"
if isinstance(value, int): return "int"
if isinstance(value, float): return "float"
if isinstance(value, str): return "str"
return type(value).__name__
def _on_tree_select(self, event=None):
selection = self.tree.selection()
if not selection: return
item = selection[0]
if item not in self.tree_items: return
key, value, path = self.tree_items[item]
self.path_label.config(text=f"/{path}" if path else "/")
self.key_var.set(str(key))
type_name = self._get_type_name(value)
self.type_label.config(text=type_name)
# ✅ 修复:同步类型单选框状态(仅标量类型有效)
if type_name in ("str", "int", "float", "bool", "null"):
self.value_type_var.set(type_name)
if isinstance(value, (dict, list)):
self.value_text.config(state=tk.NORMAL)
self.value_text.delete("1.0", tk.END)
self.value_text.insert("1.0", json.dumps(value, ensure_ascii=False, indent=2))
self.value_text.config(state=tk.DISABLED)
else:
self.value_text.config(state=tk.NORMAL)
self.value_text.delete("1.0", tk.END)
self.value_text.insert("1.0", str(value) if value is not None else "")
self._update_json_preview()
def edit_value(self):
selection = self.tree.selection()
if not selection:
messagebox.showwarning("提示", "请先选择要编辑的节点")
return
item = selection[0]
if item in self.tree_items:
_, value, _ = self.tree_items[item]
if isinstance(value, (dict, list)):
self._edit_complex_value(item)
else:
self.value_text.config(state=tk.NORMAL)
self.value_text.focus()
def _edit_complex_value(self, item: str):
key, value, path = self.tree_items[item]
dialog = tk.Toplevel(self.root)
dialog.title(f"编辑: {key}")
dialog.geometry("500x400")
dialog.transient(self.root)
dialog.grab_set()
ttk.Label(dialog, text=f"路径: /{path}", font=("Arial", 10)).pack(pady=5)
text = tk.Text(dialog, font=("Consolas", 11), wrap=tk.WORD)
text.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)
text.insert("1.0", json.dumps(value, ensure_ascii=False, indent=2))
def save():
try:
raw = text.get("1.0", tk.END).strip()
# ✅ 修复:安全解析 JSON,空内容时根据原类型提供默认值
new_value = json.loads(raw) if raw else ({} if isinstance(value, dict) else [])
self._update_yaml_data(path, new_value)
self.load_yaml_to_tree(self.yaml_data)
self._set_modified()
dialog.destroy()
except json.JSONDecodeError as e:
messagebox.showerror("错误", f"JSON 格式错误:\n{e}")
btn_frame = ttk.Frame(dialog)
btn_frame.pack(pady=10)
ttk.Button(btn_frame, text="保存", command=save).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def apply_changes(self):
selection = self.tree.selection()
if not selection:
messagebox.showwarning("提示", "请先选择节点")
return False
item = selection[0]
if item not in self.tree_items: return False
key, old_value, path = self.tree_items[item]
if isinstance(old_value, (dict, list)):
self._edit_complex_value(item)
return True
value_type = self.value_type_var.get()
text_content = self.value_text.get("1.0", tk.END).strip()
try:
if value_type == "null": new_value = None
elif value_type == "str": new_value = text_content
elif value_type == "int": new_value = int(text_content)
elif value_type == "float": new_value = float(text_content)
elif value_type == "bool": new_value = text_content.lower() in ("true", "1", "yes")
else: new_value = text_content
self._update_yaml_data(path, new_value)
self.load_yaml_to_tree(self.yaml_data)
self._set_modified()
self.status_label.config(text=f"已更新: {key} = {new_value}")
return True
except ValueError as e:
messagebox.showerror("错误", f"值转换失败:\n{e}")
return False
def reset_value(self):
self._on_tree_select()
def _update_yaml_data(self, path: str, new_value: Any):
parts = self._parse_path(path)
if not parts:
self.yaml_data = new_value # ✅ 修复:空路径表示根节点
return
data = self.yaml_data
for part in parts[:-1]:
try:
data = data[part]
except (KeyError, IndexError, TypeError) as e:
raise RuntimeError(f"路径导航失败: {path} -> {part}") from e
last_part = parts[-1]
try:
data[last_part] = new_value
except (KeyError, IndexError, TypeError) as e:
raise RuntimeError(f"更新值失败: {path} -> {last_part}") from e
def _parse_path(self, path: str) -> List:
if not path: return []
parts = []
current = ""
in_bracket = False
for char in path:
if char == "/" and not in_bracket:
if current: parts.append(current)
current = ""
elif char == "[":
if current: parts.append(current)
current = ""
in_bracket = True
elif char == "]":
if current: parts.append(int(current))
current = ""
in_bracket = False
else:
current += char
if current: parts.append(current)
return parts
def add_node(self):
selection = self.tree.selection()
if selection:
parent_item = selection[0]
if parent_item in self.tree_items:
_, parent_value, parent_path = self.tree_items[parent_item]
if not isinstance(parent_value, (dict, list)):
messagebox.showwarning("提示", "只能在字典或列表下添加")
return
else:
parent_item = ""
parent_path = ""
dialog = tk.Toplevel(self.root)
dialog.title("添加节点")
dialog.geometry("350x220")
dialog.transient(self.root)
dialog.grab_set()
ttk.Label(dialog, text="添加新节点", font=("Arial", 12, "bold")).pack(pady=10)
input_frame = ttk.Frame(dialog)
input_frame.pack(pady=10, padx=20, fill=tk.X)
ttk.Label(input_frame, text="键名:").grid(row=0, column=0, sticky=tk.W, pady=5)
key_var = tk.StringVar()
ttk.Entry(input_frame, textvariable=key_var, width=25).grid(row=0, column=1, pady=5, padx=5)
ttk.Label(input_frame, text="类型:").grid(row=1, column=0, sticky=tk.W, pady=5)
type_var = tk.StringVar(value="str")
ttk.Combobox(input_frame, textvariable=type_var, values=["str", "int", "float", "bool", "list", "dict"], width=10, state="readonly").grid(row=1, column=1, sticky=tk.W, pady=5, padx=5)
ttk.Label(input_frame, text="值:").grid(row=2, column=0, sticky=tk.W, pady=5)
value_var = tk.StringVar()
ttk.Entry(input_frame, textvariable=value_var, width=25).grid(row=2, column=1, pady=5, padx=5)
def do_add():
key = key_var.get().strip()
value_type = type_var.get()
val_text = value_var.get()
if not key:
messagebox.showwarning("提示", "键名不能为空")
return
try:
if value_type == "str": value = val_text
elif value_type == "int": value = int(val_text) if val_text else 0
elif value_type == "float": value = float(val_text) if val_text else 0.0
elif value_type == "bool": value = val_text.lower() in ("true", "1", "yes")
elif value_type == "list": value = []
elif value_type == "dict": value = {}
else: value = val_text
except ValueError:
messagebox.showerror("错误", "值格式不正确")
return
if parent_item and self.yaml_data:
parent_parts = self._parse_path(parent_path)
target = self.yaml_data
for p in parent_parts:
target = target[p]
if isinstance(target, dict):
target[key] = value
elif isinstance(target, list):
target.append(value)
else:
if not isinstance(self.yaml_data, dict):
self.yaml_data = {}
self.yaml_data[key] = value
self.load_yaml_to_tree(self.yaml_data)
self._set_modified()
dialog.destroy()
self.status_label.config(text=f"已添加: {key}")
btn_frame = ttk.Frame(dialog)
btn_frame.pack(pady=15)
ttk.Button(btn_frame, text="添加", command=do_add).pack(side=tk.LEFT, padx=5)
ttk.Button(btn_frame, text="取消", command=dialog.destroy).pack(side=tk.LEFT, padx=5)
def delete_node(self):
selection = self.tree.selection()
if not selection:
messagebox.showwarning("提示", "请选择要删除的节点")
return
item = selection[0]
if item not in self.tree_items: return
key, _, path = self.tree_items[item]
if not messagebox.askyesno("确认", f"确定删除 '{key}' 吗?"): return
try:
parts = self._parse_path(path)
if not parts:
self.yaml_data = {} # ✅ 修复:安全处理根节点删除
else:
data = self.yaml_data
for part in parts[:-1]:
data = data[part]
last_part = parts[-1]
del data[last_part]
self.load_yaml_to_tree(self.yaml_data)
self._set_modified()
self.status_label.config(text=f"已删除: {key}")
except Exception as e:
messagebox.showerror("错误", f"删除失败:\n{e}")
def new_file(self):
if self.is_modified:
if not messagebox.askyesno("确认", "当前文件未保存,是否继续?"): return
self.yaml_data = {}
self.current_file = None
self.is_modified = False
self.load_yaml_to_tree(self.yaml_data)
self._update_title()
self._update_json_preview()
self.status_label.config(text="已创建新文件")
def open_file(self):
if self.is_modified:
if not messagebox.askyesno("确认", "当前文件未保存,是否继续?"): return
file_path = filedialog.askopenfilename(title="打开 YAML 文件", filetypes=[("YAML文件", "*.yaml *.yml"), ("所有文件", "*.*")])
if file_path:
try:
with open(file_path, "r", encoding="utf-8") as f:
self.yaml_data = yaml.safe_load(f.read()) or {}
self.current_file = file_path
self.is_modified = False
self.load_yaml_to_tree(self.yaml_data)
self._update_title()
self._update_json_preview()
self.status_label.config(text=f"已打开: {file_path}")
except Exception as e:
messagebox.showerror("错误", f"无法打开文件:\n{e}")
def save_file(self):
if self.current_file:
return self._do_save(self.current_file)
return self.save_file_as()
def save_file_as(self):
file_path = filedialog.asksaveasfilename(title="保存 YAML 文件", defaultextension=".yaml", filetypes=[("YAML文件", "*.yaml"), ("YML文件", "*.yml")])
if file_path:
return self._do_save(file_path)
return False
def _do_save(self, file_path: str) -> bool:
try:
with open(file_path, "w", encoding="utf-8") as f:
yaml.dump(self.yaml_data, f, allow_unicode=True, default_flow_style=False, sort_keys=False, indent=2)
self.current_file = file_path
self.is_modified = False
self._update_title()
self.status_label.config(text=f"已保存: {file_path}")
return True
except Exception as e:
messagebox.showerror("错误", f"保存失败:\n{e}")
return False
def reload_file(self):
if self.current_file:
try:
with open(self.current_file, "r", encoding="utf-8") as f:
self.yaml_data = yaml.safe_load(f.read()) or {}
self.load_yaml_to_tree(self.yaml_data)
self._update_json_preview()
self.status_label.config(text="已刷新")
except Exception as e:
messagebox.showerror("错误", f"刷新失败:\n{e}")
else:
self.load_yaml_to_tree(self.yaml_data)
def expand_all(self):
for item in self.tree.get_children(""): self._expand_recursive(item)
def _expand_recursive(self, item: str):
self.tree.item(item, open=True)
for child in self.tree.get_children(item): self._expand_recursive(child)
def collapse_all(self):
for item in self.tree.get_children(""): self._collapse_recursive(item)
def _collapse_recursive(self, item: str):
self.tree.item(item, open=False)
for child in self.tree.get_children(item): self._collapse_recursive(child)
def search_node(self):
keyword = self.search_var.get().strip().lower()
if not keyword: return
self.collapse_all()
found = False
for item in self.tree.get_children(""):
if self._search_recursive(item, keyword): found = True
if not found: messagebox.showinfo("搜索", f"未找到: '{keyword}'")
def _search_recursive(self, item: str, keyword: str) -> bool:
found = False
text = self.tree.item(item, "text").strip().lower()
values = self.tree.item(item, "values")
display = values[0].lower() if values else ""
if keyword in text or keyword in display:
self.tree.see(item)
self.tree.selection_set(item)
self.tree.focus(item)
self.tree.item(item, open=True)
found = True
for child in self.tree.get_children(item):
if self._search_recursive(child, keyword): found = True
return found
def validate_yaml(self):
try:
yaml.dump(self.yaml_data, allow_unicode=True)
messagebox.showinfo("校验", "YAML 格式正确!")
self.status_label.config(text="校验通过")
except Exception as e:
messagebox.showerror("校验失败", f"格式错误:\n{e}")
def show_json_preview(self): self._update_json_preview()
def _update_json_preview(self):
self.json_text.config(state=tk.NORMAL)
self.json_text.delete("1.0", tk.END)
try:
if self.yaml_data is not None:
self.json_text.insert("1.0", json.dumps(self.yaml_data, ensure_ascii=False, indent=2))
else:
self.json_text.insert("1.0", "null")
except Exception as e: # ✅ 修复:捕获具体异常
self.json_text.insert("1.0", f"无法转换: {e}")
self.json_text.config(state=tk.DISABLED)
def _set_modified(self):
self.is_modified = True
self._update_title()
def _update_title(self):
filename = os.path.basename(self.current_file) if self.current_file else "未命名"
modified = " *" if self.is_modified else ""
self.root.title(f"YAML 树形编辑器 - {filename}{modified}")
def _on_close(self):
if self.is_modified:
if not messagebox.askyesno("确认", "当前文件未保存,是否退出?"): return
self.root.destroy()
def main():
root = tk.Tk()
style = ttk.Style()
try:
style.theme_use("clam")
except tk.TclError:
pass
app = TreeYAMLEditor(root)
root.mainloop()
if __name__ == "__main__":
main()

浙公网安备 33010602011771号