build_tools.py 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027
  1. #!/usr/bin/env python
  2. from json import JSONDecodeError
  3. import math
  4. import pathlib
  5. import time
  6. import traceback
  7. from typing import Callable, Dict
  8. import pkg_resources
  9. import sys
  10. import os
  11. import io
  12. from os import walk
  13. from requests import Response
  14. class Logger:
  15. NEWLINE_CHAR = '\n'
  16. with_crlf = False
  17. @classmethod
  18. def print_message(cls,message,prefix=''):
  19. if not Logger.with_crlf:
  20. trimmed=re.sub(r'\n', r'%0A', message,flags=re.MULTILINE)
  21. print(f'{prefix}{trimmed}')
  22. @classmethod
  23. def debug(cls,message):
  24. cls.print_message(message,'::debug::')
  25. @classmethod
  26. def error(cls,message):
  27. cls.print_message(message,'::error::')
  28. @classmethod
  29. def notice(cls,message):
  30. cls.print_message(message,'::notice::')
  31. @classmethod
  32. def warning(cls,message):
  33. cls.print_message(message,'::notice::')
  34. try:
  35. import argparse
  36. import collections
  37. import copy
  38. import enum
  39. import glob
  40. import json
  41. import re
  42. import shutil
  43. import stat
  44. import tempfile
  45. import zipfile
  46. from ast import literal_eval
  47. from collections import namedtuple
  48. from datetime import datetime, timedelta, timezone
  49. from json import JSONDecoder
  50. from operator import contains
  51. from platform import platform, release
  52. from pydoc import describe
  53. from time import strftime
  54. from typing import OrderedDict
  55. from urllib import response
  56. from urllib.parse import urlparse
  57. from urllib.request import Request
  58. from webbrowser import get
  59. import pygit2
  60. from pygit2 import Commit, Repository, GitError, Reference, UserPass, Index, Signature, RemoteCallbacks, Remote
  61. import requests
  62. from genericpath import isdir
  63. except ImportError as ex:
  64. Logger.error(
  65. f'Failed importing module {ex.name}, using interpreter {sys.executable}. {Logger.NEWLINE_CHAR} Installed packages:')
  66. installed_packages = pkg_resources.working_set
  67. installed_packages_list = sorted(
  68. ["%s==%s" % (i.key, i.version) for i in installed_packages])
  69. print(Logger.NEWLINE_CHAR.join(installed_packages_list))
  70. print(f'Environment: ')
  71. envlist = "\n".join([f"{k}={v}" for k, v in sorted(os.environ.items())])
  72. print(f'{envlist}')
  73. raise
  74. tool_version = "1.0.7"
  75. WEB_INSTALLER_DEFAULT_PATH = './web_installer/'
  76. FORMAT = '%(asctime)s %(message)s'
  77. github_env = type('', (), {})()
  78. manifest = {
  79. "name": "",
  80. "version": "",
  81. "home_assistant_domain": "slim_player",
  82. "funding_url": "https://esphome.io/guides/supporters.html",
  83. "new_install_prompt_erase": True,
  84. "new_install_improv_wait_time" : 20,
  85. "builds": [
  86. {
  87. "chipFamily": "ESP32",
  88. "parts": [
  89. ]
  90. }
  91. ]
  92. }
  93. artifacts_formats_outdir = '$OUTDIR'
  94. artifacts_formats_prefix = '$PREFIX'
  95. artifacts_formats = [
  96. ['build/squeezelite.bin', '$OUTDIR/$PREFIX-squeezelite.bin'],
  97. ['build/recovery.bin', '$OUTDIR/$PREFIX-recovery.bin'],
  98. ['build/ota_data_initial.bin', '$OUTDIR/$PREFIX-ota_data_initial.bin'],
  99. ['build/bootloader/bootloader.bin', '$OUTDIR/$PREFIX-bootloader.bin'],
  100. ['build/partition_table/partition-table.bin ',
  101. '$OUTDIR/$PREFIX-partition-table.bin'],
  102. ]
  103. class AttributeDict(dict):
  104. __slots__ = ()
  105. def __getattr__(self, name: str):
  106. try:
  107. return self[name.upper()]
  108. except Exception:
  109. try:
  110. return self[name.lower()]
  111. except Exception:
  112. for attr in self.keys():
  113. if name.lower() == attr.replace("'", "").lower():
  114. return self[attr]
  115. __setattr__ = dict.__setitem__
  116. parser = argparse.ArgumentParser(
  117. description='Handles some parts of the squeezelite-esp32 build process')
  118. parser.add_argument('--cwd', type=str,
  119. help='Working directory', default=os.getcwd())
  120. parser.add_argument('--with_crlf', action='store_true',help='To prevent replacing cr/lf with hex representation')
  121. parser.add_argument('--loglevel', type=str, choices={
  122. 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET'}, help='Logging level', default='INFO')
  123. subparsers = parser.add_subparsers(dest='command', required=True)
  124. parser_commits = subparsers.add_parser("list_commits",add_help=False,
  125. description="Commits list",
  126. help="Lists the last commits"
  127. )
  128. parser_changelog = subparsers.add_parser("changelog",add_help=False,
  129. description="Change Log",
  130. help="Shows the change log"
  131. )
  132. parser_dir = subparsers.add_parser("list_files",
  133. add_help=False,
  134. description="List Files parser",
  135. help="Display the content of the folder")
  136. parser_manifest = subparsers.add_parser("manifest",
  137. add_help=False,
  138. description="Manifest parser",
  139. help="Handles the web installer manifest creation")
  140. parser_manifest.add_argument('--flash_file', required=True, type=str,
  141. help='The file path which contains the firmware flashing definition')
  142. parser_manifest.add_argument(
  143. '--max_count', type=int, help='The maximum number of releases to keep', default=3)
  144. parser_manifest.add_argument(
  145. '--manif_name', required=True, type=str, help='Manifest files name and prefix')
  146. parser_manifest.add_argument(
  147. '--outdir', required=True, type=str, help='Output directory for files and manifests')
  148. parser_pushinstaller = subparsers.add_parser("pushinstaller",
  149. add_help=False,
  150. description="Web Installer Checkout parser",
  151. help="Handles the creation of artifacts files")
  152. parser_pushinstaller.add_argument(
  153. '--target', type=str, help='Output directory for web installer repository', default=WEB_INSTALLER_DEFAULT_PATH)
  154. parser_pushinstaller.add_argument(
  155. '--artifacts', type=str, help='Target subdirectory for web installer artifacts', default=WEB_INSTALLER_DEFAULT_PATH)
  156. parser_pushinstaller.add_argument(
  157. '--source', type=str, help='Source directory for the installer artifacts', default=WEB_INSTALLER_DEFAULT_PATH)
  158. parser_pushinstaller.add_argument('--url', type=str, help='Web Installer clone url ',
  159. default='https://github.com/sle118/squeezelite-esp32-installer.git')
  160. parser_pushinstaller.add_argument(
  161. '--web_installer_branch', type=str, help='Web Installer branch to use ', default='main')
  162. parser_pushinstaller.add_argument(
  163. '--token', type=str, help='Auth token for pushing changes')
  164. parser_pushinstaller.add_argument(
  165. '--flash_file', type=str, help='Manifest json file path')
  166. parser_pushinstaller.add_argument(
  167. '--manif_name', required=True, type=str, help='Manifest files name and prefix')
  168. parser_environment = subparsers.add_parser("environment",
  169. add_help=False,
  170. description="Environment parser",
  171. help="Updates the build environment")
  172. parser_environment.add_argument(
  173. '--env_file', type=str, help='Environment File', default=os.environ.get('GITHUB_ENV'))
  174. parser_environment.add_argument(
  175. '--build', required=True, type=int, help='The build number')
  176. parser_environment.add_argument(
  177. '--node', required=True, type=str, help='The matrix node being built')
  178. parser_environment.add_argument(
  179. '--depth', required=True, type=int, help='The bit depth being built')
  180. parser_environment.add_argument(
  181. '--major', type=str, help='Major version', default='2')
  182. parser_environment.add_argument(
  183. '--docker', type=str, help='Docker image to use', default='sle118/squeezelite-esp32-idfv43')
  184. parser_show = subparsers.add_parser("show",
  185. add_help=False,
  186. description="Show parser",
  187. help="Show the build environment")
  188. parser_build_flags = subparsers.add_parser("build_flags",
  189. add_help=False,
  190. description="Build Flags",
  191. help="Updates the build environment with build flags")
  192. parser_build_flags.add_argument(
  193. '--mock', action='store_true', help='Mock release')
  194. parser_build_flags.add_argument(
  195. '--force', action='store_true', help='Force a release build')
  196. parser_build_flags.add_argument(
  197. '--ui_build', action='store_true', help='Include building the web UI')
  198. def format_commit(commit):
  199. # 463a9d8b7 Merge branch 'bugfix/ci_deploy_tags_v4.0' into 'release/v4.0' (2020-01-11T14:08:55+08:00)
  200. dt = datetime.fromtimestamp(float(commit.author.time), timezone(
  201. timedelta(minutes=commit.author.offset)))
  202. #timestr = dt.strftime('%c%z')
  203. timestr = dt.strftime('%F %R %Z')
  204. cmesg:str = commit.message.replace('\n', ' ').replace('\r','').replace('*','-')
  205. return f'{commit.short_id} {cmesg} ({timestr}) <{commit.author.name}>'.replace(' ', ' ', )
  206. def get_github_data(repo: Repository, api):
  207. base_url = urlparse(repo.remotes['origin'].url)
  208. print(
  209. f'Base URL is {base_url.path} from remote URL {repo.remotes["origin"].url}')
  210. url_parts = base_url.path.split('.')
  211. for p in url_parts:
  212. print(f'URL Part: {p}')
  213. api_url = f"{url_parts[0]}/{api}"
  214. print(f'API to call: {api_url}')
  215. url = f"https://api.github.com/repos{api_url}"
  216. resp = requests.get(
  217. url, headers={"Content-Type": "application/vnd.github.v3+json"})
  218. return json.loads(resp.text)
  219. def dump_directory(dir_path):
  220. # list to store files name
  221. res = []
  222. for (dir_path, dir_names, file_names) in walk(dir_path):
  223. res.extend(file_names)
  224. print(res)
  225. class ReleaseDetails():
  226. version: str
  227. idf: str
  228. platform: str
  229. branch: str
  230. bitrate: str
  231. def __init__(self, tag: str) -> None:
  232. self.version, self.idf, self.platform, self.branch = tag.split('#')
  233. try:
  234. self.version, self.bitrate = self.version.split('-')
  235. except Exception:
  236. pass
  237. def get_attributes(self):
  238. return {
  239. 'version': self.version,
  240. 'idf': self.idf,
  241. 'platform': self.platform,
  242. 'branch': self.branch,
  243. 'bitrate': self.bitrate
  244. }
  245. def format_prefix(self) -> str:
  246. return f'{self.branch}-{self.platform}-{self.version}'
  247. def get_full_platform(self):
  248. return f"{self.platform}{f'-{self.bitrate}' if self.bitrate is not None else ''}"
  249. class BinFile():
  250. name: str
  251. offset: int
  252. source_full_path: str
  253. target_name: str
  254. target_fullpath: str
  255. artifact_relpath: str
  256. def __init__(self, source_path, file_build_path: str, offset: int, release_details: ReleaseDetails, build_dir) -> None:
  257. self.name = os.path.basename(file_build_path).rstrip()
  258. self.artifact_relpath = os.path.relpath(
  259. file_build_path, build_dir).rstrip()
  260. self.source_path = source_path
  261. self.source_full_path = os.path.join(
  262. source_path, file_build_path).rstrip()
  263. self.offset = offset
  264. self.target_name = f'{release_details.format_prefix()}-{release_details.bitrate}-{self.name}'.rstrip()
  265. def get_manifest(self):
  266. return {"path": self.target_name, "offset": self.offset}
  267. def copy(self, target_folder) -> str:
  268. self.target_fullpath = os.path.join(target_folder, self.target_name)
  269. Logger.debug(
  270. f'File {self.source_full_path} will be copied to {self.target_fullpath}')
  271. try:
  272. os.makedirs(target_folder, exist_ok=True)
  273. shutil.copyfile(self.source_full_path,
  274. self.target_fullpath, follow_symlinks=True)
  275. except Exception as ex:
  276. Logger.error(f"Error while copying {self.source_full_path} to {self.target_fullpath}{Logger.NEWLINE_CHAR}Content of {os.path.dirname(self.source_full_path.rstrip())}:{Logger.NEWLINE_CHAR}{Logger.NEWLINE_CHAR.join(get_file_list(os.path.dirname(self.source_full_path.rstrip())))}")
  277. raise
  278. return self.target_fullpath
  279. def get_attributes(self):
  280. return {
  281. 'name': self.target_name,
  282. 'offset': self.offset,
  283. 'artifact_relpath': self.artifact_relpath
  284. }
  285. class PlatformRelease():
  286. name: str
  287. description: str
  288. url: str = ''
  289. zipfile: str = ''
  290. tempfolder: str
  291. release_details: ReleaseDetails
  292. flash_parms = {}
  293. build_dir: str
  294. has_artifacts: bool
  295. branch: str
  296. assets: list
  297. bin_files: list
  298. name_prefix: str
  299. flash_file_path: str
  300. def get_manifest_name(self) -> str:
  301. return f'{self.name_prefix}-{self.release_details.format_prefix()}-{self.release_details.bitrate}.json'
  302. def __init__(self, flash_file_path, git_release, build_dir, branch, name_prefix) -> None:
  303. self.name = git_release.tag_name
  304. self.description = git_release.body
  305. self.assets = git_release['assets']
  306. self.has_artifacts = False
  307. self.name_prefix = name_prefix
  308. if len(self.assets) > 0:
  309. if self.has_asset_type():
  310. self.url = self.get_asset_from_extension().browser_download_url
  311. if self.has_asset_type('.zip'):
  312. self.zipfile = self.get_asset_from_extension(
  313. ext='.zip').browser_download_url
  314. self.has_artifacts = True
  315. self.release_details = ReleaseDetails(git_release.name)
  316. self.bin_files = list()
  317. self.flash_file_path = flash_file_path
  318. self.build_dir = os.path.relpath(build_dir)
  319. self.branch = branch
  320. def process_files(self, outdir: str) -> list:
  321. parts = []
  322. for f in self.bin_files:
  323. f.copy(outdir)
  324. parts.append(f.get_manifest())
  325. return parts
  326. def get_asset_from_extension(self, ext='.bin'):
  327. for a in self.assets:
  328. filename = AttributeDict(a).name
  329. file_name, file_extension = os.path.splitext(filename)
  330. if file_extension == ext:
  331. return AttributeDict(a)
  332. return None
  333. def has_asset_type(self, ext='.bin') -> bool:
  334. return self.get_asset_from_extension(ext) is not None
  335. def platform(self):
  336. return self.release_details.get_full_platform()
  337. def get_zip_file(self):
  338. self.tempfolder = extract_files_from_archive(self.zipfile)
  339. print(
  340. f'Artifacts for {self.name} extracted to {self.tempfolder}')
  341. flash_parms_file = os.path.relpath(
  342. self.tempfolder+self.flash_file_path)
  343. line: str
  344. with open(flash_parms_file) as fin:
  345. for line in fin:
  346. components = line.split()
  347. if len(components) == 2:
  348. self.flash_parms[os.path.basename(
  349. components[1]).rstrip().lstrip()] = components[0]
  350. try:
  351. for artifact in artifacts_formats:
  352. base_name = os.path.basename(artifact[0]).rstrip().lstrip()
  353. self.bin_files.append(BinFile(
  354. self.tempfolder, artifact[0], self.flash_parms[base_name], self.release_details, self.build_dir))
  355. has_artifacts = True
  356. except Exception:
  357. self.has_artifacts = False
  358. def cleanup(self):
  359. Logger.debug(f'removing temp directory for platform release {self.name}')
  360. shutil.rmtree(self.tempfolder)
  361. def get_attributes(self):
  362. return {
  363. 'name': self.name,
  364. 'branch': self.branch,
  365. 'description': self.description,
  366. 'url': self.url,
  367. 'zipfile': self.zipfile,
  368. 'release_details': self.release_details.get_attributes(),
  369. 'bin_files': [b.get_attributes() for b in self.bin_files],
  370. 'manifest_name': self.get_manifest_name()
  371. }
  372. class Releases():
  373. _dict: dict = collections.OrderedDict()
  374. maxcount: int = 0
  375. branch: str = ''
  376. repo: Repository = None
  377. last_commit: Commit = None
  378. manifest_name: str
  379. def __init__(self, branch: str, maxcount: int = 3) -> None:
  380. self.maxcount = maxcount
  381. self.branch = branch
  382. def count(self, value: PlatformRelease) -> int:
  383. content = self._dict.get(value.platform())
  384. if content == None:
  385. return 0
  386. return len(content)
  387. def get_platform(self, platform: str) -> list:
  388. return self._dict[platform]
  389. def get_platform_keys(self):
  390. return self._dict.keys()
  391. def get_all(self) -> list:
  392. result: list = []
  393. for platform in [self.get_platform(platform) for platform in self.get_platform_keys()]:
  394. for release in platform:
  395. result.append(release)
  396. return result
  397. def append(self, value: PlatformRelease):
  398. if self.count(value) == 0:
  399. self._dict[value.platform()] = []
  400. if self.should_add(value):
  401. print(f'Adding release {value.name} to the list')
  402. self._dict[value.platform()].append(value)
  403. else:
  404. print(f'Skipping release {value.name}')
  405. def get_attributes(self):
  406. res = []
  407. release: PlatformRelease
  408. for release in self.get_all():
  409. res.append(release.get_attributes())
  410. return res
  411. def get_minlen(self) -> int:
  412. return min([len(self.get_platform(p)) for p in self.get_platform_keys()])
  413. def got_all_packages(self) -> bool:
  414. return self.get_minlen() >= self.maxcount
  415. def should_add(self, release: PlatformRelease) -> bool:
  416. return self.count(release) <= self.maxcount
  417. def add_package(self, package: PlatformRelease, with_artifacts: bool = True):
  418. if self.branch != package.branch:
  419. Logger.debug(f'Skipping release {package.name} from branch {package.branch}')
  420. elif package.has_artifacts or not with_artifacts:
  421. self.append(package)
  422. @classmethod
  423. def get_last_commit_message(cls, repo_obj: Repository = None) -> str:
  424. last: Commit = cls.get_last_commit(repo_obj)
  425. if last is None:
  426. return ''
  427. else:
  428. return last.message.replace(Logger.NEWLINE_CHAR, ' ')
  429. @classmethod
  430. def get_last_author(cls, repo_obj: Repository = None) -> Signature:
  431. last: Commit = cls.get_last_commit(repo_obj)
  432. return last.author
  433. @classmethod
  434. def get_last_committer(cls, repo_obj: Repository = None) -> Signature:
  435. last: Commit = cls.get_last_commit(repo_obj)
  436. return last.committer
  437. @classmethod
  438. def get_last_commit(cls, repo_obj: Repository = None) -> Commit:
  439. loc_repo = repo_obj
  440. if cls.repo is None:
  441. cls.load_repository(os.getcwd())
  442. if loc_repo is None:
  443. loc_repo = cls.repo
  444. head: Reference = loc_repo.head
  445. target = head.target
  446. ref: Reference
  447. if cls.last_commit is None:
  448. try:
  449. cls.last_commit = loc_repo[target]
  450. print(
  451. f'Last commit for {head.shorthand} is {format_commit(cls.last_commit)}')
  452. except Exception as e:
  453. Logger.error(
  454. f'Unable to retrieve last commit for {head.shorthand}/{target}: {e}')
  455. cls.last_commit = None
  456. return cls.last_commit
  457. @classmethod
  458. def load_repository(cls, path: str = os.getcwd()) -> Repository:
  459. if cls.repo is None:
  460. try:
  461. print(f'Opening repository from {path}')
  462. cls.repo = Repository(path=path)
  463. except GitError as ex:
  464. Logger.error(f"Unable to access the repository({ex}).\nContent of {path}:\n{Logger.NEWLINE_CHAR.join(get_file_list(path, 1))}")
  465. raise
  466. return cls.repo
  467. @classmethod
  468. def resolve_commit(cls, repo: Repository, commit_id: str) -> Commit:
  469. commit: Commit
  470. reference: Reference
  471. commit, reference = repo.resolve_refish(commit_id)
  472. return commit
  473. @classmethod
  474. def get_branch_name(cls) -> str:
  475. return re.sub('[^a-zA-Z0-9\-~!@_\.]', '', cls.load_repository().head.shorthand)
  476. @classmethod
  477. def get_release_branch(cls, repo: Repository, platform_release) -> str:
  478. match = [t for t in repo.branches.with_commit(
  479. platform_release.target_commitish)]
  480. no_origin = [t for t in match if 'origin' not in t]
  481. if len(no_origin) == 0 and len(match) > 0:
  482. return match[0].split('/')[1]
  483. elif len(no_origin) > 0:
  484. return no_origin[0]
  485. return ''
  486. @classmethod
  487. def get_flash_parms(cls, file_path):
  488. flash = parse_json(file_path)
  489. od: collections.OrderedDict = collections.OrderedDict()
  490. for z in flash['flash_files'].items():
  491. base_name: str = os.path.basename(z[1])
  492. od[base_name.rstrip().lstrip()] = literal_eval(z[0])
  493. return collections.OrderedDict(sorted(od.items()))
  494. @classmethod
  495. def get_releases(cls, flash_file_path, maxcount: int, name_prefix):
  496. repo = Releases.load_repository(os.getcwd())
  497. packages: Releases = cls(branch=repo.head.shorthand, maxcount=maxcount)
  498. build_dir = os.path.dirname(flash_file_path)
  499. for page in range(1, 999):
  500. Logger.debug(f'Getting releases page {page}')
  501. releases = get_github_data(
  502. repo, f'releases?per_page=50&page={page}')
  503. if len(releases) == 0:
  504. Logger.debug(f'No more release found for page {page}')
  505. break
  506. for release_entry in [AttributeDict(platform) for platform in releases]:
  507. packages.add_package(PlatformRelease(flash_file_path, release_entry, build_dir,
  508. Releases.get_release_branch(repo, release_entry), name_prefix))
  509. if packages.got_all_packages():
  510. break
  511. if packages.got_all_packages():
  512. break
  513. return packages
  514. @classmethod
  515. def get_commit_list(cls) -> list:
  516. commit_list = []
  517. last: Commit = Releases.get_last_commit()
  518. if last is None:
  519. return commit_list
  520. try:
  521. for c in Releases.load_repository().walk(last.id, pygit2.GIT_SORT_TIME):
  522. if '[skip actions]' not in c.message:
  523. commit_list.append(format_commit(c))
  524. if len(commit_list) > 10:
  525. break
  526. except Exception as e:
  527. Logger.error(
  528. f'Unable to get commit list starting at {last.id}: {e}')
  529. return commit_list
  530. @classmethod
  531. def get_commit_list_descriptions(cls) -> str:
  532. # return '<<~EOD\n### Revision Log\n'+Logger.NEWLINE_CHAR.join(cls.get_commit_list())+'\n~EOD'
  533. return '<<~EOD\n### Revision Log\n'+Logger.NEWLINE_CHAR.join(cls.get_commit_list())+'\n~EOD'
  534. @classmethod
  535. def get_changelog(cls) -> str:
  536. # return '<<~EOD\n### Revision Log\n'+Logger.NEWLINE_CHAR.join(cls.get_commit_list())+'\n~EOD'
  537. fname = os.path.abspath('CHANGELOG')
  538. folder: str = os.path.abspath(os.path.dirname(fname))
  539. print(f'Opening changelog file {fname} from {folder}')
  540. try:
  541. with open(fname) as f:
  542. content = f.read()
  543. Logger.debug(f'Change Log:\n{content}')
  544. return f'<<~EOD\n{content}\n~EOD'
  545. except Exception as ex:
  546. Logger.error(
  547. f"Unable to load change log file content. Content of {folder}:{Logger.NEWLINE_CHAR.join(get_file_list(folder))}")
  548. raise
  549. return f'<<~EOD\n### Revision Log\n\n~EOD'
  550. def update(self, *args, **kwargs):
  551. if args:
  552. if len(args) > 1:
  553. raise TypeError("update expected at most 1 arguments, "
  554. "got %d" % len(args))
  555. other = dict(args[0])
  556. for key in other:
  557. self[key] = other[key]
  558. for key in kwargs:
  559. self[key] = kwargs[key]
  560. def setdefault(self, key, value=None):
  561. if key not in self:
  562. self[key] = value
  563. return self[key]
  564. def set_workdir(args):
  565. print(f'setting work dir to: {args.cwd}')
  566. os.chdir(os.path.abspath(args.cwd))
  567. def parse_json(filename: str):
  568. fname = os.path.abspath(filename)
  569. folder: str = os.path.abspath(os.path.dirname(filename))
  570. print(f'Opening json file {fname} from {folder}')
  571. try:
  572. with open(fname) as f:
  573. content = f.read()
  574. Logger.debug(f'Loading json\n{content}')
  575. return json.loads(content)
  576. except JSONDecodeError as ex:
  577. Logger.error(f'Error parsing {content}')
  578. except Exception as ex:
  579. Logger.error(
  580. f"Unable to parse flasher args json file. Content of {folder}:{Logger.NEWLINE_CHAR.join(get_file_list(folder))}")
  581. raise
  582. def write_github_env_file(values,env_file):
  583. env_file_stream = None
  584. if env_file is not None:
  585. print(f'Writing content to {env_file}...')
  586. env_file_stream = open(env_file, "w")
  587. else:
  588. print(f'Writing content to console...')
  589. env_file_stream = sys.stdout
  590. for attr in [attr for attr in dir(values) if not attr.startswith('_')]:
  591. line = f'{attr}{"=" if attr != "description" else ""}{getattr(values,attr)}'
  592. if env_file is not None:
  593. print(line)
  594. env_file_stream.write(f'{line}\n')
  595. os.environ[attr] = str(getattr(values, attr))
  596. if env_file is not None:
  597. print(f'Done writing to {env_file}!')
  598. env_file_stream.close()
  599. else:
  600. print(f'Done Writing content to console...')
  601. def format_artifact_from_manifest(manif_json: AttributeDict):
  602. if len(manif_json) == 0:
  603. return 'Newest release'
  604. first = manif_json[0]
  605. return f'{first["branch"]}-{first["release_details"]["version"]}'
  606. def format_artifact_name(base_name: str = '', args=AttributeDict(os.environ)):
  607. return f'{base_name}{args.branch_name}-{args.node}-{args.depth}-{args.major}{args.build}'
  608. def handle_build_flags(args):
  609. set_workdir(args)
  610. print('Setting global build flags')
  611. commit_message: str = Releases.get_last_commit_message()
  612. github_env.mock = 1 if args.mock else 0
  613. github_env.release_flag = 1 if args.mock or args.force or 'release' in commit_message.lower() else 0
  614. github_env.ui_build = 1 if args.mock or args.ui_build or '[ui-build]' in commit_message.lower() else 0
  615. write_github_env_file(github_env,os.environ.get('GITHUB_OUTPUT'))
  616. def write_version_number(file_path:str,env_details):
  617. # app_name="${TARGET_BUILD_NAME}.${DEPTH}.dev-$(git log --pretty=format:'%h' --max-count=1).${branch_name}"
  618. # echo "${app_name}">version.txt
  619. try:
  620. version:str = f'{env_details.TARGET_BUILD_NAME}.{env_details.DEPTH}.{env_details.major}.{env_details.BUILD_NUMBER}.{env_details.branch_name}'
  621. with open(file_path, "w") as version_file:
  622. version_file.write(version)
  623. except Exception as ex:
  624. Logger.error(f'Unable to set version string {version} in file {file_path}')
  625. raise Exception('Version error')
  626. Logger.notice(f'Firmware version set to {version}')
  627. def handle_environment(args):
  628. set_workdir(args)
  629. print('Setting environment variables...')
  630. commit_message: str = Releases.get_last_commit_message()
  631. last: Commit = Releases.get_last_commit()
  632. if last is not None:
  633. github_env.author_name = last.author.name
  634. github_env.author_email = last.author.email
  635. github_env.committer_name = last.committer.name
  636. github_env.committer_email = last.committer.email
  637. github_env.node = args.node
  638. github_env.depth = args.depth
  639. github_env.major = args.major
  640. github_env.build = args.build
  641. github_env.DEPTH = args.depth
  642. github_env.TARGET_BUILD_NAME = args.node
  643. github_env.build_version_prefix = args.major
  644. github_env.branch_name = Releases.get_branch_name()
  645. github_env.BUILD_NUMBER = str(args.build)
  646. github_env.tag = f'{args.node}.{args.depth}.{args.build}.{github_env.branch_name}'.rstrip()
  647. github_env.last_commit = commit_message
  648. github_env.DOCKER_IMAGE_NAME = args.docker
  649. github_env.name = f"{args.major}.{str(args.build)}-{args.depth}#v4.3#{args.node}#{github_env.branch_name}"
  650. github_env.artifact_prefix = format_artifact_name(
  651. 'squeezelite-esp32-', github_env)
  652. github_env.artifact_file_name = f"{github_env.artifact_prefix}.zip"
  653. github_env.artifact_bin_file_name = f"{github_env.artifact_prefix}.bin"
  654. github_env.PROJECT_VER = f'{args.node}-{ args.build }'
  655. github_env.description = Releases.get_changelog()
  656. write_github_env_file(github_env,args.env_file)
  657. write_version_number("version.txt",github_env)
  658. def handle_artifacts(args):
  659. set_workdir(args)
  660. print(f'Handling artifacts')
  661. for attr in artifacts_formats:
  662. target: str = os.path.relpath(attr[1].replace(artifacts_formats_outdir, args.outdir).replace(
  663. artifacts_formats_prefix, format_artifact_name()))
  664. source: str = os.path.relpath(attr[0])
  665. target_dir: str = os.path.dirname(target)
  666. print(f'Copying file {source} to {target}')
  667. try:
  668. os.makedirs(target_dir, exist_ok=True)
  669. shutil.copyfile(source, target, follow_symlinks=True)
  670. except Exception as ex:
  671. Logger.error(f"Error while copying {source} to {target}\nContent of {target_dir}:\n{Logger.NEWLINE_CHAR.join(get_file_list(os.path.dirname(attr[0].rstrip())))}")
  672. raise
  673. def delete_folder(path):
  674. '''Remov Read Only Files'''
  675. for root, dirs, files in os.walk(path, topdown=True):
  676. for dir in dirs:
  677. fulldirpath = os.path.join(root, dir)
  678. Logger.debug(f'Drilling down in {fulldirpath}')
  679. delete_folder(fulldirpath)
  680. for fname in files:
  681. full_path = os.path.join(root, fname)
  682. Logger.debug(f'Setting file read/write {full_path}')
  683. os.chmod(full_path, stat.S_IWRITE)
  684. Logger.debug(f'Deleting file {full_path}')
  685. os.remove(full_path)
  686. if os.path.exists(path):
  687. Logger.debug(f'Changing folder read/write {path}')
  688. os.chmod(path, stat.S_IWRITE)
  689. print(f'WARNING: Deleting Folder {path}')
  690. os.rmdir(path)
  691. def get_file_stats(path):
  692. fstat: os.stat_result = pathlib.Path(path).stat()
  693. # Convert file size to MB, KB or Bytes
  694. mtime = time.strftime("%X %x", time.gmtime(fstat.st_mtime))
  695. if (fstat.st_size > 1024 * 1024):
  696. return math.ceil(fstat.st_size / (1024 * 1024)), "MB", mtime
  697. elif (fstat.st_size > 1024):
  698. return math.ceil(fstat.st_size / 1024), "KB", mtime
  699. return fstat.st_size, "B", mtime
  700. def get_file_list(root_path, max_levels: int = 2) -> list:
  701. outlist: list = []
  702. for root, dirs, files in os.walk(root_path):
  703. path = os.path.relpath(root).split(os.sep)
  704. if len(path) <= max_levels:
  705. outlist.append(f'\n{root}')
  706. for file in files:
  707. full_name = os.path.join(root, file)
  708. fsize, unit, mtime = get_file_stats(full_name)
  709. outlist.append('{:s} {:8d} {:2s} {:18s}\t{:s}'.format(
  710. len(path) * "---", fsize, unit, mtime, file))
  711. return outlist
  712. def get_recursive_list(path) -> list:
  713. outlist: list = []
  714. for root, dirs, files in os.walk(path, topdown=True):
  715. for fname in files:
  716. outlist.append((fname, os.path.join(root, fname)))
  717. return outlist
  718. def handle_manifest(args):
  719. set_workdir(args)
  720. print(f'Creating the web installer manifest')
  721. outdir: str = os.path.relpath(args.outdir)
  722. if not os.path.exists(outdir):
  723. print(f'Creating target folder {outdir}')
  724. os.makedirs(outdir, exist_ok=True)
  725. releases: Releases = Releases.get_releases(
  726. args.flash_file, args.max_count, args.manif_name)
  727. release: PlatformRelease
  728. for release in releases.get_all():
  729. manifest_name = release.get_manifest_name()
  730. release.get_zip_file()
  731. man = copy.deepcopy(manifest)
  732. man['manifest_name'] = manifest_name
  733. man['builds'][0]['parts'] = release.process_files(args.outdir)
  734. man['name'] = release.platform()
  735. man['version'] = release.release_details.version
  736. Logger.debug(f'Generated manifest: \n{json.dumps(man)}')
  737. fullpath = os.path.join(args.outdir, release.get_manifest_name())
  738. print(f'Writing manifest to {fullpath}')
  739. with open(fullpath, "w") as f:
  740. json.dump(man, f, indent=4)
  741. release.cleanup()
  742. mainmanifest = os.path.join(args.outdir, args.manif_name)
  743. print(f'Writing main manifest {mainmanifest}')
  744. with open(mainmanifest, 'w') as f:
  745. json.dump(releases.get_attributes(), f, indent=4)
  746. def get_new_file_names(manif_json) -> collections.OrderedDict():
  747. new_release_files: dict = collections.OrderedDict()
  748. for artifact in manif_json:
  749. for name in [f["name"] for f in artifact["bin_files"]]:
  750. new_release_files[name] = artifact
  751. new_release_files[artifact["manifest_name"]] = artifact["name"]
  752. return new_release_files
  753. def copy_no_overwrite(source: str, target: str):
  754. sfiles = os.listdir(source)
  755. for f in sfiles:
  756. source_file = os.path.join(source, f)
  757. target_file = os.path.join(target, f)
  758. if not os.path.exists(target_file):
  759. print(f'Copying {f} to target')
  760. shutil.copy(source_file, target_file)
  761. else:
  762. Logger.debug(f'Skipping existing file {f}')
  763. def get_changed_items(repo: Repository) -> Dict:
  764. changed_filemode_status_code: int = pygit2.GIT_FILEMODE_TREE
  765. original_status_dict: Dict[str, int] = repo.status()
  766. # transfer any non-filemode changes to a new dictionary
  767. status_dict: Dict[str, int] = {}
  768. for filename, code in original_status_dict.items():
  769. if code != changed_filemode_status_code:
  770. status_dict[filename] = code
  771. return status_dict
  772. def is_dirty(repo: Repository) -> bool:
  773. return len(get_changed_items(repo)) > 0
  774. def push_with_method(auth_method:str,token:str,remote: Remote,reference):
  775. success:bool = False
  776. try:
  777. remote.push(reference, callbacks=RemoteCallbacks(pygit2.UserPass(auth_method, token)))
  778. success=True
  779. except Exception as ex:
  780. Logger.error(f'Error pushing with auth method {auth_method}: {ex}.')
  781. return success
  782. def push_if_change(repo: Repository, token: str, source_path: str, manif_json):
  783. if is_dirty(repo):
  784. print(f'Changes found. Preparing commit')
  785. env = AttributeDict(os.environ)
  786. index: Index = repo.index
  787. index.add_all()
  788. index.write()
  789. reference = repo.head.name
  790. message = f'Web installer for {format_artifact_from_manifest(manif_json)}'
  791. tree = index.write_tree()
  792. Releases.load_repository(source_path)
  793. commit = repo.create_commit(reference, Releases.get_last_author(
  794. ), Releases.get_last_committer(), message, tree, [repo.head.target])
  795. origin: Remote = repo.remotes['origin']
  796. print(
  797. f'Pushing commit {format_commit(repo[commit])} to url {origin.url}')
  798. remote: Remote = repo.remotes['origin']
  799. auth_methods = ['x-access-token','x-oauth-basic']
  800. for method in auth_methods:
  801. if push_with_method(method, token, remote, [reference]):
  802. print(f'::notice Web installer updated for {format_artifact_from_manifest(manif_json)}')
  803. return
  804. raise Exception('Unable to push web installer.')
  805. else:
  806. print(f'WARNING: No change found. Skipping update')
  807. def update_files(target_artifacts: str, manif_json, source: str):
  808. new_list: dict = get_new_file_names(manif_json)
  809. if os.path.exists(target_artifacts):
  810. print(f'Removing obsolete files from {target_artifacts}')
  811. for entry in get_recursive_list(target_artifacts):
  812. f = entry[0]
  813. full_target = entry[1]
  814. if f not in new_list.keys():
  815. print(f'WARNING: Removing obsolete file {f}')
  816. os.remove(full_target)
  817. else:
  818. print(f'Creating target folder {target_artifacts}')
  819. os.makedirs(target_artifacts, exist_ok=True)
  820. print(f'Copying installer files to {target_artifacts}:')
  821. copy_no_overwrite(os.path.abspath(source), target_artifacts)
  822. def handle_pushinstaller(args):
  823. set_workdir(args)
  824. print('Pushing web installer updates... ')
  825. target_artifacts = os.path.join(args.target, args.artifacts)
  826. if os.path.exists(args.target):
  827. print(f'Removing files (if any) from {args.target}')
  828. delete_folder(args.target)
  829. print(f'Cloning from {args.url} into {args.target}')
  830. repo = pygit2.clone_repository(args.url, args.target)
  831. repo.checkout_head()
  832. manif_json = parse_json(os.path.join(args.source, args.manif_name))
  833. update_files(target_artifacts, manif_json, args.source)
  834. push_if_change(repo, args.token, args.cwd, manif_json)
  835. repo.state_cleanup()
  836. def handle_show(args):
  837. print('Show')
  838. def extract_files_from_archive(url):
  839. tempfolder = tempfile.mkdtemp()
  840. platform:Response = requests.get(url)
  841. Logger.debug(f'Downloading {url} to {tempfolder}')
  842. Logger.debug(f'Transfer status code: {platform.status_code}. Expanding content')
  843. z = zipfile.ZipFile(io.BytesIO(platform.content))
  844. z.extractall(tempfolder)
  845. return tempfolder
  846. def handle_list_files(args):
  847. print(f'Content of {args.cwd}:')
  848. print(Logger.NEWLINE_CHAR.join(get_file_list(args.cwd)))
  849. def handle_commits(args):
  850. set_workdir(args)
  851. print(Releases.get_commit_list_descriptions())
  852. def handle_changelog(args):
  853. set_workdir(args)
  854. print(Releases.get_changelog())
  855. parser_environment.set_defaults(func=handle_environment, cmd='environment')
  856. parser_manifest.set_defaults(func=handle_manifest, cmd='manifest')
  857. parser_pushinstaller.set_defaults(func=handle_pushinstaller, cmd='installer')
  858. parser_show.set_defaults(func=handle_show, cmd='show')
  859. parser_build_flags.set_defaults(func=handle_build_flags, cmd='build_flags')
  860. parser_dir.set_defaults(func=handle_list_files, cmd='list_files')
  861. parser_commits.set_defaults(func=handle_commits,cmd='list_commits')
  862. parser_changelog.set_defaults(func=handle_changelog,cmd='changelog')
  863. def main():
  864. exit_result_code = 0
  865. args = parser.parse_args()
  866. Logger.with_crlf = args.with_crlf
  867. print(f'::group::{args.command}')
  868. print(f'build_tools version : {tool_version}')
  869. print(f'Processing command {args.command}')
  870. func: Callable = getattr(args, 'func', None)
  871. if func is not None:
  872. # Call whatever subcommand function was selected
  873. e: Exception
  874. try:
  875. func(args)
  876. except Exception as e:
  877. Logger.error(f'Critical error while running {args.command}\n{" ".join(traceback.format_exception(etype=type(e), value=e, tb=e.__traceback__))}')
  878. exit_result_code = 1
  879. else:
  880. # No subcommand was provided, so call help
  881. parser.print_usage()
  882. print(f'::endgroup::')
  883. sys.exit(exit_result_code)
  884. if __name__ == '__main__':
  885. main()