文件

488 行
22 KiB
Python

"""Create unrouted KiCad boards with verified net import and mechanical anchors.
Run with the Python shipped with KiCad. Refuse to overwrite edited boards.
Electrical parts awaiting layout are grouped outside the board, not silently
presented as a completed placement. No EasyEDA tracks or copper are imported.
"""
import hashlib
import json
import math
from pathlib import Path
import shutil
import subprocess
import xml.etree.ElementTree as ET
import pcbnew as pcb
from check_hardware import Board
from schematic_maintenance import ROOT, parse, child, children
CONFIG = json.loads((ROOT / 'pcb-mechanical.json').read_text(encoding='utf-8'))
OUT = ROOT / 'out/pcb-start'
MECH_LIB = ROOT / 'lib/PhoneRail-Mechanical.pretty'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def xy(x, y):
return pcb.VECTOR2I(pcb.FromMM(x), pcb.FromMM(y))
def top(x, y):
ox, oy = CONFIG['origin_kicad_mm']
return ox + x, oy + CONFIG['size_mm'][1] - y
def site(n, u, v):
x, y, angle = CONFIG['phone_sites'][str(n)]
if angle == 180:
u, v = CONFIG['phone_size_mm'][0] - u, CONFIG['phone_size_mm'][1] - v
return top(x + u, y + v)
def layer_set(*layers):
result = pcb.LSET()
for layer in layers:
result.AddLayer(layer)
return result
def draw_line(board, a, b, layer=pcb.Edge_Cuts, width=.05):
item = pcb.PCB_SHAPE()
item.SetShape(pcb.SHAPE_T_SEGMENT)
item.SetStart(xy(*a))
item.SetEnd(xy(*b))
item.SetLayer(layer)
item.SetWidth(pcb.FromMM(width))
board.Add(item)
def rounded_rect(board, x, y, w, h, r, layer=pcb.Edge_Cuts):
points = [(x+r,y), (x+w-r,y), (x+w,y+r), (x+w,y+h-r),
(x+w-r,y+h), (x+r,y+h), (x,y+h-r), (x,y+r)]
for i in (0, 2, 4, 6):
draw_line(board, points[i], points[i+1], layer)
for i, center, mid_angle in [(1,(x+w-r,y+r),-45), (3,(x+w-r,y+h-r),45),
(5,(x+r,y+h-r),135), (7,(x+r,y+r),225)]:
mid = (center[0]+r*math.cos(math.radians(mid_angle)),
center[1]+r*math.sin(math.radians(mid_angle)))
arc = pcb.PCB_SHAPE()
arc.SetShape(pcb.SHAPE_T_ARC)
arc.SetArcGeometry(xy(*points[i]), xy(*mid), xy(*points[(i+1) % 8]))
arc.SetLayer(layer)
arc.SetWidth(pcb.FromMM(.05))
board.Add(arc)
def label(board, text, x, y, size=1, layer=pcb.Dwgs_User):
item = pcb.PCB_TEXT(board)
item.SetText(text)
item.SetPosition(xy(x, y))
item.SetTextSize(xy(size, size))
item.SetTextThickness(pcb.FromMM(size / 7))
item.SetLayer(layer)
board.Add(item)
def keepout(board, name, rect, layers, copper=False):
zone = pcb.ZONE(board)
zone.SetIsRuleArea(True)
zone.SetZoneName(name)
zone.SetLayerSet(layer_set(*layers))
zone.SetDoNotAllowFootprints(True)
zone.SetDoNotAllowTracks(copper)
zone.SetDoNotAllowVias(copper)
zone.SetDoNotAllowPads(copper)
zone.SetDoNotAllowZoneFills(copper)
x0, y0, x1, y1 = rect
outline = zone.Outline()
outline.NewOutline()
for x, y in [(x0,y0), (x1,y0), (x1,y1), (x0,y1)]:
outline.Append(pcb.FromMM(x), pcb.FromMM(y))
board.Add(zone)
def mechanical_library():
MECH_LIB.mkdir(exist_ok=True)
for name, drill, pad, radius, plated in [
('M3_GND_D3.2', 3.2, 6, 3.5, True),
('M1.6_NPTH_D1.8', 1.8, 1.8, CONFIG['phone_hole_courtyard_radius_mm'], False)]:
fp = pcb.FOOTPRINT(None)
fp.SetFPIDAsString('PhoneRail-Mechanical:' + name)
fp.SetReference('H**')
fp.SetValue(name)
fp.SetAttributes(pcb.FP_BOARD_ONLY | pcb.FP_EXCLUDE_FROM_BOM | pcb.FP_EXCLUDE_FROM_POS_FILES)
hole = pcb.PAD(fp)
hole.SetNumber('1' if plated else '')
hole.SetAttribute(pcb.PAD_ATTRIB_PTH if plated else pcb.PAD_ATTRIB_NPTH)
hole.SetShape(pcb.PAD_SHAPE_CIRCLE)
hole.SetSize(xy(pad, pad))
hole.SetDrillSize(xy(drill, drill))
layers = pcb.LSET.AllCuMask()
layers.AddLayer(pcb.F_Mask)
layers.AddLayer(pcb.B_Mask)
hole.SetLayerSet(layers)
fp.Add(hole)
for layer in (pcb.F_CrtYd, pcb.B_CrtYd):
shape = pcb.PCB_SHAPE(fp)
shape.SetShape(pcb.SHAPE_T_CIRCLE)
shape.SetCenter(xy(0, 0))
shape.SetEnd(xy(radius, 0))
shape.SetWidth(pcb.FromMM(.05))
shape.SetLayer(layer)
fp.Add(shape)
fp.Reference().SetVisible(False)
fp.Value().SetVisible(False)
pcb.PCB_IO_MGR.FindPlugin(pcb.PCB_IO_MGR.KICAD_SEXP).FootprintSave(str(MECH_LIB), fp)
path = MECH_LIB/(name+'.kicad_mod')
path.write_text(pth_apertures(path.read_text(encoding='utf-8')),encoding='utf-8')
def mount(board, name, ref, position, gnd=None):
fp = pcb.FootprintLoad(str(MECH_LIB), name)
fp.SetReference(ref)
fp.SetPosition(xy(*position))
fp.SetLocked(True)
if gnd:
for pad in fp.Pads():
pad.SetNet(gnd)
board.Add(fp)
def place(fp, position, rotation=0, bottom=False, locked=True):
fp.SetPosition(xy(*position))
if bottom:
fp.Flip(fp.GetPosition(), pcb.FLIP_DIRECTION_TOP_BOTTOM)
fp.SetOrientationDegrees(rotation)
fp.SetLocked(locked)
fp.SetField('Placement', 'MECHANICAL_ANCHOR' if locked else 'STAGED_NOT_PLACED')
for field in fp.GetFields():
if field.GetName() not in {'Reference', 'Value'}:
field.SetVisible(False)
field.SetLayer(pcb.B_Fab if bottom else pcb.F_Fab)
field.SetMirrored(bottom)
fp.Value().SetVisible(False)
fp.Reference().SetTextSize(xy(.8, .8))
fp.Reference().SetTextThickness(pcb.FromMM(.12))
box = fp.GetBoundingBox(False, False)
fp.Reference().SetPosition(pcb.VECTOR2I(fp.GetPosition().x, box.GetTop()-pcb.FromMM(1)))
def stage(board, parts, source, number):
groups = {}
for ref, fp in parts.items():
if fp.IsLocked():
continue
groups.setdefault(source.parts[ref]['page'], []).append(fp)
xstart = CONFIG['origin_kicad_mm'][0] + CONFIG['size_mm'][0] + 15
ystart = CONFIG['origin_kicad_mm'][1]
column_y = [ystart] * 3
for index, (page, fps) in enumerate(sorted(groups.items())):
col = index % 3
left, y = xstart + col*77, column_y[col] + 6
label(board, page.removesuffix('.kicad_sch'), left+32, y-4, 1.2)
cursor, row_height = left, 0
for fp in sorted(fps, key=lambda f: f.GetReference()):
place(fp, (0, 0), bottom=number == 2, locked=False)
box = fp.GetBoundingBox(False, False)
w, h = pcb.ToMM(box.GetWidth()), pcb.ToMM(box.GetHeight())
if cursor+w > left+70 and cursor > left:
cursor, y, row_height = left, y+row_height+4, 0
offset = (cursor-pcb.ToMM(box.GetLeft()), y-pcb.ToMM(box.GetTop()))
fp.Move(xy(*offset))
cursor += w+3
row_height = max(row_height, h)
column_y[col] = y+row_height+7
def add_stackup(path):
source = path.read_text(encoding='utf-8')
setup = child(parse(source), 'setup')
layers = ['F.Cu','In1.Cu','In2.Cu','B.Cu']
items = ['(stackup']
for i, layer in enumerate(layers):
items.append(f'(layer "{layer}" (type "copper") (thickness {CONFIG["copper_mm"][i]}))')
if i < 3:
kind = 'core' if i == 1 else 'prepreg'
items.append(f'(layer "dielectric {i+1}" (type "{kind}") '
f'(thickness {CONFIG["dielectric_mm"][i]}) (material "FR4") '
'(epsilon_r 4.3) (loss_tangent 0.02))')
items.append('(copper_finish "ENIG") (dielectric_constraints no))')
old = child(setup, 'stackup')
start, end = (old.start, old.end) if old else (setup.end-1, setup.end-1)
source = source[:start]+'\n'.join(items)+'\n'+source[end:]
path.write_text(pth_apertures(source), encoding='utf-8')
def pth_apertures(source):
# Explicit no-tenting survives a native load/save cycle in KiCad 10.
edits = []
root = parse(source)
footprints = [root] if root[0] == 'footprint' else children(root,'footprint')
for footprint in footprints:
for pad in children(footprint,'pad'):
if pad[2] in {'thru_hole','np_thru_hole'}:
layers = child(pad,'layers')
edits.append((layers.start,layers.end,'(layers "*.Cu" "*.Mask")'))
if not child(pad,'tenting'):
edits.append((pad.end-1,pad.end-1,' (tenting (front no) (back no))\n'))
for start,end,replacement in sorted(edits,reverse=True):
source = source[:start]+replacement+source[end:]
return source
def native_net_names(source):
if hasattr(source, 'pcb_net_names'):
return source.pcb_net_names
path = OUT/f'Board{source.number}.net'
result = subprocess.run(['D:/KiCad/bin/kicad-cli.exe','sch','export','netlist',
'--format','kicadsexpr','-o',str(path),str(source.path)],
capture_output=True,encoding='utf-8',errors='replace',timeout=60)
path.with_suffix('.net.log').write_text(result.stdout+result.stderr,encoding='utf-8')
if result.returncode:
raise RuntimeError(f'Native netlist failed: {path}')
nets = child(parse(path.read_text(encoding='utf-8')),'nets')
by_members = {frozenset((child(node,'ref')[1],child(node,'pin')[1]) for node in children(net,'node')):
child(net,'name')[1] for net in children(nets,'net')}
# XML is human-readable; the native netlist retains KiCad's escaped names.
source.pcb_net_names = {name:by_members[frozenset(members)] for name,members in source.net_members.items()}
return source.pcb_net_names
def project_settings(number):
path = ROOT / f'PhoneRail-Board{number}/PhoneRail-Board{number}.kicad_pro'
project = json.loads(path.read_text(encoding='utf-8'))
net = project.setdefault('net_settings', {})
base = dict(net['classes'][0])
base.update(clearance=.15, track_width=.25, via_diameter=.6, via_drill=.3,
diff_pair_width=.28, diff_pair_gap=.18, diff_pair_via_gap=.25)
classes = [dict(base, name='Default')]
for name, width, clearance in [('USB90',.28,.15), ('PWR4A',1.2,.2),
('PWR5V',.8,.2), ('PWR12V',2,.2)]:
classes.append(dict(base, name=name, track_width=width, clearance=clearance))
net['classes'] = classes
patterns = [(n, 'PWR4A') for n in ['VO*_P', *[f'VO{i}' for i in range(1,6)],
*[f'Net-(U{i}-VIN+)' for i in range(8,13)]]]
patterns += [(n, 'PWR12V') for n in ['+12V', '*/FAN_12V_SW', 'Net-(J1-+)', 'Net-(D1-C)']]
patterns += [(n, 'PWR5V') for n in ['+5V', *[f'VBUS{i}' for i in range(1,6)]]]
patterns += [(n, 'USB90') for n in ['USB*_DP','USB*_DM','USB*_DPJ','USB*_DMJ','UP_DP','UP_DM']]
net['netclass_patterns'] = [{'pattern': p, 'netclass': c} for p, c in patterns]
settings = project['board'].setdefault('design_settings', {})
settings.setdefault('rules', {}).update(min_clearance=.127, min_track_width=.127,
min_via_diameter=.6, min_through_hole_diameter=.3, min_hole_to_hole=.25,
min_copper_edge_clearance=.3, solder_mask_clearance=.05, solder_mask_min_width=.1)
return path, json.dumps(project, indent=2, ensure_ascii=False)+'\n'
def build(number, source):
board = pcb.BOARD()
board.SetCopperLayerCount(4)
board.GetDesignSettings().SetBoardThickness(pcb.FromMM(CONFIG['thickness_mm']))
board.GetTitleBlock().SetTitle(f'PhoneRail Board{number} R1 - placement start')
board.GetTitleBlock().SetRevision('R1-PLACEMENT-START')
origin = CONFIG['origin_kicad_mm']
rounded_rect(board, *origin, *CONFIG['size_mm'], CONFIG['corner_radius_mm'])
nets = {}
names = native_net_names(source)
for name in source.net_members:
net = pcb.NETINFO_ITEM(board, names[name])
board.Add(net)
nets[name] = net
tree = ET.parse(OUT / f'Board{number}.xml')
root_uuid = child(parse(source.path.read_text(encoding='utf-8')), 'uuid')[1]
parts = {}
for c in tree.findall('components/comp'):
ref, fpname = c.get('ref'), c.findtext('footprint')
if not fpname:
raise ValueError(f'{ref}: no footprint')
lib, name = fpname.split(':')
if lib == 'PhoneRail':
assert 'phonerail-y-axis-corrected-r1' in (ROOT/f'lib/{lib}.pretty/{name}.kicad_mod').read_text(encoding='utf-8'), 'Run repair_footprint_handedness.py before importing the migrated library'
fp = pcb.FootprintLoad(str(ROOT / f'lib/{lib}.pretty'), name)
if fp is None:
raise ValueError(f'{ref}: missing {fpname}')
fp.SetFPIDAsString(fpname)
fp.SetReference(ref)
fp.SetValue(c.findtext('value'))
fp.SetSheetname(source.parts[ref]['page'].removesuffix('.kicad_sch'))
fp.SetSheetfile(source.parts[ref]['page'])
fp.SetExcludedFromBOM(source.parts[ref]['in_bom'] != 'yes')
fp.SetExcludedFromPosFiles(source.parts[ref]['in_bom'] != 'yes')
fp.SetDNP(source.parts[ref]['dnp'] == 'yes')
for field in c.findall('fields/field'):
if field.get('name') not in {'Reference','Value','Footprint'}:
fp.SetField(field.get('name'), field.text or '')
path = pcb.KIID_PATH()
ids = [root_uuid, *c.find('sheetpath').get('tstamps').strip('/').split('/'), c.findtext('tstamps')]
for uid in filter(None, ids):
path.push_back(pcb.KIID(uid))
fp.SetPath(path)
board.Add(fp)
for pad in fp.Pads():
net_name = source.pins.get((ref, pad.GetNumber()))
if net_name:
pad.SetNet(nets[net_name])
parts[ref] = fp
for side, position in CONFIG['headers_source_mm'].items():
ref = {1: {'left':'J24','right':'J25'}, 2: {'left':'J31','right':'J32'}}[number][side]
place(parts[ref], top(*position), bottom=number == 2)
holes = list(CONFIG['mounting_holes_common_mm'])
if number == 2:
holes += CONFIG['mounting_holes_carrier_only_mm']
for i, point in enumerate(holes, 1):
mount(board, 'M3_GND_D3.2', f'MH{i}', top(*point), nets['GND'])
slot_rectangles = []
if number == 2:
place(parts['J23'], top(*CONFIG['usb_upstream_source_mm']), rotation=270, bottom=True)
for n in range(1, 6):
x, y, angle = CONFIG['phone_sites'][str(n)]
w, h = CONFIG['phone_size_mm']
lx, ly = top(x, y+h)
rounded_rect(board, lx, ly, w, h, .5, pcb.Dwgs_User)
label(board, f'CH{n} PHONE - 1mm GAP', lx+w/2, ly+h/2, 1.2)
for name, pos in CONFIG['phone_holes_mm'].items():
if name not in CONFIG['phone_holes_skipped']:
mount(board, 'M1.6_NPTH_D1.8', f'P{n}{name}', site(n, *pos))
for index, pos in enumerate(CONFIG['phone_key_contacts_mm']):
ref = f'J{41+(n-1)*4+index}'
place(parts[ref], site(n, *pos), rotation=angle)
# A separate label outside the contact cluster prevents ink on contacts.
parts[ref].Reference().SetVisible(False)
for prefix, key in [('J8','battery_connector_local_mm'), ('J7','tail_connector_local_mm')]:
place(parts[f'{prefix}{n}'], site(n, *CONFIG[key]), rotation=angle, bottom=True)
for name, rect in CONFIG['slots_local_mm'].items():
a, b = site(n, *rect[:2]), site(n, *rect[2:])
x0,y0,x1,y1 = min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1])
rounded_rect(board, x0,y0,x1-x0,y1-y0,CONFIG['slot_corner_radius_mm'])
margin = CONFIG['slot_clearance_mm']
keepout(board, f'CH{n}_{name}_CUTOUT', (x0-margin,y0-margin,x1+margin,y1+margin),
[pcb.F_Cu,pcb.In1_Cu,pcb.In2_Cu,pcb.B_Cu], copper=True)
slot_rectangles.append([x0,y0,x1,y1])
for name, rect in CONFIG['ribbon_lanes_local_mm'].items():
a,b = site(n,*rect[:2]),site(n,*rect[2:])
keepout(board, f'CH{n}_{name}_RIBBON',
(min(a[0],b[0]),min(a[1],b[1]),max(a[0],b[0]),max(a[1],b[1])), [pcb.B_Cu])
label(board, f'BOARD {number} R1 - UNROUTED', origin[0]+110, origin[1]-5, 1.8)
stage(board, parts, source, number)
destination = OUT / f'PhoneRail-Board{number}.kicad_pcb'
pcb.SaveBoard(str(destination), board)
add_stackup(destination)
reloaded = pcb.LoadBoard(str(destination))
report = verify_import(reloaded, source, number)
report['internal_slots'] = len(slot_rectangles)
return destination, reloaded, report
def verify_import(board, source, number, allow_routes=False):
parts = {f.GetReference(): f for f in board.GetFootprints()}
electrical = {r: f for r, f in parts.items() if r in source.parts}
assert set(electrical) == set(source.parts), 'Component import mismatch'
checked = 0
names = native_net_names(source)
for ref, fp in electrical.items():
expected = {pin: net for (r, pin), net in source.pins.items() if r == ref}
pads = {}
for pad in fp.Pads():
pads.setdefault(pad.GetNumber(), []).append(pad)
assert set(expected) <= set(pads), (ref, 'Missing pads', set(expected)-set(pads))
for pin, net in expected.items():
assert all(p.GetNetname() == names[net] for p in pads[pin]), (ref, pin, net)
checked += len(pads[pin])
for pad in fp.Pads():
if pad.GetAttribute() in {pcb.PAD_ATTRIB_PTH,pcb.PAD_ATTRIB_NPTH}:
assert pad.GetLayerSet().Contains(pcb.F_Mask) and pad.GetLayerSet().Contains(pcb.B_Mask), (ref,'missing PTH mask openings')
assert fp.GetPath().size() >= 2, (ref, 'Missing schematic UUID path')
assert fp.GetFPIDAsString() == source.parts[ref]['Footprint']
if number == 2 and ref.startswith('J') and ref[1:].isdigit() and 41 <= int(ref[1:]) <= 60:
assert all(not p.GetLayerSet().Contains(pcb.F_Paste) for p in fp.Pads()), ref
assert fp.IsExcludedFromBOM() and fp.IsExcludedFromPosFiles(), ref
assert board.GetCopperLayerCount() == 4
assert abs(pcb.ToMM(board.GetDesignSettings().GetBoardThickness())-1.6) < .001
if not allow_routes:
assert len(list(board.GetTracks())) == 0
if number == 2:
usb = electrical['J23']
assert all(p.GetPosition().x < usb.GetPosition().x for p in usb.Pads()
if p.GetNumber() in {'A5','B5','A6','B6','A7','B7'}), 'USB mouth faces inward'
for n in range(1,6):
fp = electrical[f'J7{n}']
pads = {p.GetNumber():p for p in fp.Pads()}
# In local phone coordinates, VBUS anchor is -u, even row is +v.
sign = -1 if CONFIG['phone_sites'][str(n)][2] == 180 else 1
assert (pads['51'].GetPosition().x-fp.GetPosition().x)*sign < 0
assert (pads['2'].GetPosition().y-fp.GetPosition().y)*sign < 0
return {'electrical_footprints':len(electrical), 'mechanical_footprints':len(parts)-len(electrical),
'pad_net_assignments_checked':checked, 'net_count':len(source.net_members),
'anchored_electrical_footprints':sum(f.IsLocked() for f in electrical.values()),
'staged_electrical_footprints':sum(any(field.GetName() == 'Placement' and field.GetText() == 'STAGED_NOT_PLACED'
for field in f.GetFields()) for f in electrical.values()),
'tracks':len(list(board.GetTracks())), 'copper_layers':4, 'thickness_mm':1.6}
def mating_check(lower, upper):
boards = [{f.GetReference():f for f in b.GetFootprints()} for b in (lower, upper)]
count = 0
for lref, uref in [('J24','J31'),('J25','J32')]:
lp = {p.GetNumber():p for p in boards[0][lref].Pads()}
up = {p.GetNumber():p for p in boards[1][uref].Pads()}
for pin in range(1,41):
a, b = lp[str(pin)], up[str(pin+1 if pin % 2 else pin-1)]
assert (a.GetPosition()-b.GetPosition()).EuclideanNorm() < pcb.FromMM(.001), (lref,pin,'misaligned')
assert a.GetNetname() == b.GetNetname(), (lref,pin,'wrong net')
count += 1
return count
def main():
OUT.mkdir(exist_ok=True)
manifest_path = OUT / 'pcb-start.json'
old = json.loads(manifest_path.read_text(encoding='utf-8')) if manifest_path.exists() else {}
for n in (1,2):
name = f'PhoneRail-Board{n}'
for suffix in ('.kicad_pcb', '.kicad_dru'):
target = ROOT / name / (name+suffix)
if target.exists() and digest(target) != old.get('generated_files', {}).get(str(target.relative_to(ROOT))):
raise RuntimeError(f'Refusing to overwrite an edited or untracked file: {target}')
mechanical_library()
results, boards, artifacts = {}, [], []
for n in (1,2):
source = Board(n)
source.compile(r'D:\KiCad\bin\kicad-cli.exe', OUT)
temporary, board, report = build(n, source)
results[f'Board{n}'] = report
boards.append(board)
artifacts.append((temporary, ROOT/source.name/(source.name+'.kicad_pcb')))
results['mated_pins_checked'] = mating_check(*boards)
results['generated_files'] = {}
rules = '''(version 1)
(rule "USB90 geometry target"
(condition "A.NetClass == 'USB90'")
(constraint diff_pair_gap (min 0.15) (opt 0.18) (max 0.21))
(constraint diff_pair_uncoupled (max 5)))
'''
for temporary, target in artifacts:
shutil.copyfile(temporary, target)
n = 1 if 'Board1' in target.stem else 2
project_path, content = project_settings(n)
project_path.write_text(content, encoding='utf-8')
rule_path = target.with_suffix('.kicad_dru')
rule_path.write_text(rules, encoding='utf-8')
table_path = target.parent / 'fp-lib-table'
table = table_path.read_text(encoding='utf-8')
if 'PhoneRail-Mechanical' not in table:
node = parse(table)
entry = '(lib (name "PhoneRail-Mechanical") (type "KiCad") (uri "${KIPRJMOD}/../lib/PhoneRail-Mechanical.pretty") (options "") (descr "R1 mechanical anchors"))\n'
table_path.write_text(table[:node.end-1]+entry+table[node.end-1:], encoding='utf-8')
for path in (target, rule_path):
results['generated_files'][str(path.relative_to(ROOT))] = digest(path)
results['status'] = 'PLACEMENT_START_NOT_ROUTED_NOT_RELEASED'
results['mechanical_config_sha256'] = digest(ROOT/'pcb-mechanical.json')
manifest_path.write_text(json.dumps(results,indent=2)+'\n',encoding='utf-8')
print(json.dumps(results, indent=2))
if __name__ == '__main__':
main()