dep11-basic-validate.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. #!/usr/bin/env python3
  2. #
  3. # Copyright (C) 2015 Matthias Klumpp <mak@debian.org>
  4. #
  5. # This program is free software; you can redistribute it and/or
  6. # modify it under the terms of the GNU Lesser General Public
  7. # License as published by the Free Software Foundation; either
  8. # version 3.0 of the License, or (at your option) any later version.
  9. #
  10. # This program is distributed in the hope that it will be useful,
  11. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  12. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
  13. # Lesser General Public License for more details.
  14. #
  15. # You should have received a copy of the GNU Lesser General Public
  16. # License along with this program.
  17. import os
  18. import sys
  19. import yaml
  20. import gzip
  21. import lzma
  22. from voluptuous import Schema, Required, All, Length, Match, Url
  23. from optparse import OptionParser
  24. import multiprocessing as mp
  25. schema_header = Schema({
  26. Required('File'): All(str, 'DEP-11', msg='Must be "DEP-11"'),
  27. Required('Origin'): All(str, Length(min=1)),
  28. Required('Version'): All(str, Match(r'(\d+\.?)+$'), msg='Must be a valid version number'),
  29. Required('MediaBaseUrl'): All(str, Url()),
  30. 'Time': All(str),
  31. 'Priority': All(int),
  32. })
  33. schema_translated = Schema({
  34. Required('C'): All(str, Length(min=1), msg='Must have an unlocalized \'C\' key'),
  35. dict: All(str, Length(min=1)),
  36. }, extra=True)
  37. schema_component = Schema({
  38. Required('Type'): All(str, Length(min=1)),
  39. Required('ID'): All(str, Length(min=1)),
  40. Required('Name'): All(dict, Length(min=1), schema_translated),
  41. Required('Summary'): All(dict, Length(min=1)),
  42. }, extra=True)
  43. def add_issue(msg):
  44. print(msg)
  45. def test_custom_objects(lines):
  46. ret = True
  47. for i in range(0, len(lines)):
  48. if '!!python/' in lines[i]:
  49. add_issue('Python object encoded in line %i.' % (i))
  50. ret = False
  51. return ret
  52. def test_localized_dict(doc, ldict, id_string):
  53. ret = True
  54. for lang, value in ldict.items():
  55. if lang == 'x-test':
  56. add_issue('[%s][%s]: %s' % (doc['ID'], id_string, 'Found cruft locale: x-test'))
  57. if lang == 'xx':
  58. add_issue('[%s][%s]: %s' % (doc['ID'], id_string, 'Found cruft locale: xx'))
  59. if lang.endswith('.UTF-8'):
  60. add_issue('[%s][%s]: %s' % (doc['ID'], id_string, 'AppStream locale names should not specify encoding (ends with .UTF-8)'))
  61. if ' ' in lang:
  62. add_issue('[%s][%s]: %s' % (doc['ID'], id_string, 'Locale name contains space: "%s"' % (lang)))
  63. # this - as opposed to the other issues - is an error
  64. ret = False
  65. return ret
  66. def test_localized(doc, key):
  67. ldict = doc.get(key, None)
  68. if not ldict:
  69. return True
  70. return test_localized_dict(doc, ldict, key)
  71. def validate_data(data):
  72. ret = True
  73. lines = data.split('\n')
  74. # see if there are any Python-specific objects encoded
  75. ret = test_custom_objects(lines)
  76. try:
  77. docs = yaml.safe_load_all(data)
  78. header = next(docs)
  79. except Exception as e:
  80. add_issue('Could not parse file: %s' % (str(e)))
  81. return False
  82. try:
  83. schema_header(header)
  84. except Exception as e:
  85. add_issue('Invalid DEP-11 header: %s' % (str(e)))
  86. ret = False
  87. for doc in docs:
  88. cptid = doc.get('ID')
  89. pkgname = doc.get('Package')
  90. cpttype = doc.get('Type')
  91. if not doc:
  92. add_issue('FATAL: Empty document found.')
  93. ret = False
  94. continue
  95. if not cptid:
  96. add_issue('FATAL: Component without ID found.')
  97. ret = False
  98. continue
  99. if not pkgname:
  100. if doc.get('Merge'):
  101. # merge instructions do not need a package name
  102. continue
  103. if cpttype not in ['web-application', 'operating-system', 'repository']:
  104. add_issue('[%s]: %s' % (cptid, 'Component is missing a \'Package\' key.'))
  105. ret = False
  106. continue
  107. try:
  108. schema_component(doc)
  109. except Exception as e:
  110. add_issue('[%s]: %s' % (cptid, str(e)))
  111. ret = False
  112. continue
  113. # more tests for the icon key
  114. icon = doc.get('Icon')
  115. if cpttype in ['desktop-application', 'web-application']:
  116. if not doc.get('Icon'):
  117. add_issue('[%s]: %s' % (cptid, 'Components containing an application must have an \'Icon\' key.'))
  118. ret = False
  119. if icon:
  120. if (not icon.get('stock')) and (not icon.get('cached')) and (not icon.get('local')):
  121. add_issue('[%s]: %s' % (cptid, 'A \'stock\', \'cached\' or \'local\' icon must at least be provided. @ data[\'Icon\']'))
  122. ret = False
  123. if not test_localized(doc, 'Name'):
  124. ret = False
  125. if not test_localized(doc, 'Summary'):
  126. ret = False
  127. if not test_localized(doc, 'Description'):
  128. ret = False
  129. if not test_localized(doc, 'DeveloperName'):
  130. ret = False
  131. for shot in doc.get('Screenshots', list()):
  132. caption = shot.get('caption')
  133. if caption:
  134. if not test_localized_dict(doc, caption, 'Screenshots.x.caption'):
  135. ret = False
  136. return ret
  137. def validate_file(fname):
  138. if fname.endswith('.gz'):
  139. opener = gzip.open
  140. elif fname.endswith('.xz'):
  141. opener = lzma.open
  142. else:
  143. opener = open
  144. with opener(fname, 'rt', encoding='utf-8') as fh:
  145. data = fh.read()
  146. return validate_data(data)
  147. def validate_dir(dirname):
  148. ret = True
  149. asfiles = []
  150. # find interesting files
  151. for root, subfolders, files in os.walk(dirname):
  152. for fname in files:
  153. fpath = os.path.join(root, fname)
  154. if os.path.islink(fpath):
  155. add_issue('FATAL: Symlinks are not allowed')
  156. return False
  157. if fname.endswith('.yml.gz') or fname.endswith('.yml.xz'):
  158. asfiles.append(fpath)
  159. # validate the files, use multiprocessing to speed up the validation
  160. with mp.Pool() as pool:
  161. results = [pool.apply_async(validate_file, (fname,)) for fname in asfiles]
  162. for res in results:
  163. if not res.get():
  164. ret = False
  165. return ret
  166. def main():
  167. parser = OptionParser()
  168. (options, args) = parser.parse_args()
  169. if len(args) < 1:
  170. print('You need to specify a file to validate!')
  171. sys.exit(4)
  172. fname = args[0]
  173. if os.path.isdir(fname):
  174. ret = validate_dir(fname)
  175. elif os.path.islink(fname):
  176. add_issue('FATAL: Symlinks are not allowed')
  177. ret = False
  178. else:
  179. ret = validate_file(fname)
  180. if ret:
  181. msg = 'DEP-11 basic validation successful.'
  182. else:
  183. msg = 'DEP-11 validation failed!'
  184. print(msg)
  185. if not ret:
  186. sys.exit(1)
  187. if __name__ == '__main__':
  188. main()