2
0

build_tools.py 37 KB

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