]> git.pld-linux.org Git - projects/pld-ftp-admin.git/blame - modules/ftptree.py
Support for split debugsource packaes
[projects/pld-ftp-admin.git] / modules / ftptree.py
CommitLineData
098f4a50
MM
1# vi: encoding=utf-8 ts=8 sts=4 sw=4 et
2
2ec96333
JR
3from __future__ import print_function
4
3943100f
JR
5import os
6import config
7import string
8try:
9 import urllib.request as urlmess
10except ImportError:
11 import urllib as urlmess
12 pass
13import re
14import rpm
81e9387d 15from common import fileexists, noarchcachedir
151d31cc 16from baseftptree import BasePkg, BaseFtpTree
aeb928ae 17from sign import is_signed
098f4a50 18
aeb928ae
ER
19errnum = 0
20quietmode = False
0a108b7f 21
76c9cb26 22class SomeError(Exception):
db126e0b
ER
23 def __init__(self):
24 return
25
26 def __str__(self):
2ec96333 27 return "An Error occured!"
db126e0b 28
098f4a50
MM
29def bailoutonerror():
30 if not errnum == 0:
2ec96333 31 print("%d error(s) encountered... aborting" % errnum)
9c170c61 32 raise SomeError()
098f4a50 33
d9b3388c 34def pinfo(msg):
2ec96333 35 print('INFO: ' + msg)
d9b3388c 36
098f4a50
MM
37def perror(msg):
38 global errnum
213a164a 39 errnum = errnum + 1
2ec96333 40 print('ERR: ' + msg)
098f4a50 41
796b7867 42def pwarning(msg):
2ec96333 43 print('WARN: ' + msg)
796b7867 44
213a164a 45def rm(file, test = False):
a0b52be0
AM
46 if test:
47 if not os.path.exists(file):
48 pinfo("TEST os.remove(%s): file doesn't exists" % file)
49 else:
2d70dbd8
AM
50 try:
51 os.remove(file)
9c170c61 52 except OSError as e:
2d70dbd8 53 pinfo("os.remove(%s): %s" % (file, e))
8911f226 54 #raise
a0b52be0 55
213a164a 56def mv(src, dst, test = False):
a0b52be0 57 fsrc = src
213a164a 58 fdst = dst + '/' + src.split('/')[-1]
a0b52be0 59 if test:
85d5b2e6 60 if not os.path.exists(fsrc):
a0b52be0 61 pinfo("TEST os.rename(%s, %s): source doesn't exists" % (fsrc, fdst))
c4647bc3
ER
62 if not os.path.exists(dst):
63 pinfo("TEST destination doesn't exist: %s" % dst)
a0b52be0
AM
64 else:
65 try:
66 os.rename(fsrc, fdst)
9c170c61 67 except OSError as e:
aee80850 68 pinfo("os.rename(%s, %s): %s" % (fsrc, fdst, e))
a0b52be0 69 raise
098f4a50 70
151d31cc 71class Pkg(BasePkg):
85f3481a
MM
72 def __init__(self, nvr, tree):
73 BasePkg.__init__(self, nvr, tree)
cea52869 74 self.name = '-'.join(nvr.split('-')[:-2])
8911f226
ER
75 self.version = nvr.split('-')[-2]
76 self.release = nvr.split('-')[-1]
77 self.marked4removal = False
78 self.marked4moving = False
79 self.marked4movingpool = []
80 self.errors = []
81 self.warnings = []
098f4a50 82
d7667ebe
MM
83 def __cmp__(self, pkg):
84 if self.name > pkg.name:
85 return 1
86 elif self.name < pkg.name:
87 return -1
88 else:
89 return rpm.labelCompare(('0', self.version, self.release),
90 ('0', pkg.version, pkg.release))
91
54ff0049
ER
92
93 # unfortunately can't do new Pkg(NVR), and have no "tree" in this pkg context
94 # so this static function
95 def is_debuginfo(self, nvr):
96 """
97 returns true if NVR is debuginfo package and separate debuginfo is enabled
98 """
99 if not config.separate_debuginfo:
100 return False
101 pkg = nvr.split('-')[:-2]
20313fad 102 return pkg[-1] == 'debuginfo' or pkg[-1] == 'debugsource'
54ff0049 103
ef010074
ER
104 def is_sourcefile(self, file):
105 """
106 returns true if file is source package
107 """
108 return file[-8:] == '.src.rpm'
109
8643d2cd
ER
110 # returns true if package build is integer
111 def is_release(self):
112 """
113 To account Release tags with subver macros, we consider integer release
114 if it contains odd number of dots:
115
116 1 -> True
117 0.1 -> False
118 0.%{subver}.%{rel}, %{rel} = 1 -> 0.20010.1 -> True
119 0.%{subver}.%{rel}, %{rel} = 0.1 -> 0.20010.0.1 -> False
120 """
1af4af39 121 return self.release.count('.') % 2 == 0
8643d2cd 122
14085d11
MM
123 def mark4moving(self):
124 if not self.marked4moving:
f301e305
MM
125 # Only one pkg in this pool can be marked for moving
126 for pkg in self.marked4movingpool:
127 pkg.unmark4moving()
14085d11
MM
128 self.tree.marked4moving.append(self)
129 self.marked4moving=True
130
f301e305
MM
131 def unmark4moving(self):
132 if self.marked4moving:
133 self.tree.marked4moving.remove(self)
134 self.marked4moving=False
135
14085d11
MM
136 def mark4removal(self):
137 if not self.marked4removal:
138 self.tree.marked4removal.append(self)
139 self.marked4removal=True
098f4a50 140
85f3481a
MM
141 def error(self, msg):
142 self.errors.append(msg)
143 if not quietmode:
144 perror('%s %s' % (self.nvr, msg))
145
146 def warning(self, msg):
147 self.warnings.append(msg)
148 if not quietmode:
149 pwarning('%s %s' % (self.nvr, msg))
150
bb2cb325
MM
151 def load(self, content=None):
152 BasePkg.load(self, content)
b353ff36 153 if 'move' in self.info:
14085d11 154 self.mark4moving()
098f4a50
MM
155
156 def writeinfo(self):
8911f226 157 f = open(self.tree.basedir+'/SRPMS/.metadata/'+self.nvr+'.src.rpm.info', 'w')
098f4a50
MM
158 for bid in self.build.keys():
159 f.write("info:build:%s:requester:%s\ninfo:build:%s:requester_email:%s\n" % (bid, self.build[bid].requester, bid, self.build[bid].requester_email))
160 for key in self.info.keys():
cea52869 161 f.write("info:%s:%s\n" % (key, ':'.join(self.info[key])))
151d31cc
MM
162 for arch in self.files.keys():
163 for rpm in self.files[arch]:
098f4a50 164 f.write("file:%s:%s\n" % (arch, rpm))
e50c8ad1 165
8911f226
ER
166 def remove(self, test = False):
167 """
168 Remove package from ftp
169 """
151d31cc
MM
170 for arch in self.files.keys():
171 for rpm in self.files[arch]:
54ff0049
ER
172 if self.is_debuginfo(rpm):
173 rm(self.tree.basedir + '/' + arch + '/debuginfo/' + rpm, test)
174 else:
175 rm(self.tree.basedir + '/' + arch + '/RPMS/' + rpm, test)
8911f226
ER
176 if arch == 'noarch':
177 if fileexists(noarchcachedir + rpm + '.filelist'):
178 rm(noarchcachedir + rpm + '.filelist', test)
179 if fileexists(noarchcachedir + rpm + '.reqlist'):
180 rm(noarchcachedir + rpm + '.reqlist', test)
181 rm(self.tree.basedir + '/SRPMS/.metadata/' + self.nvr + '.src.rpm.info', test)
098f4a50 182
ef010074 183 def rpmfiles(self, debugfiles = True, sourcefiles = True):
d38e8382
ER
184 """
185 Return rpm files related to this package
186 """
187 files = []
188 for arch, rpms in self.files.items():
189 for nvr in rpms:
190 if self.is_debuginfo(nvr):
3dd276bd
ER
191 if debugfiles:
192 files.append(self.tree.basedir + '/' + arch + '/debuginfo/' + nvr)
d38e8382 193 else:
ef010074
ER
194 if self.is_sourcefile(nvr):
195 if sourcefiles:
196 files.append(self.tree.basedir + '/' + arch + '/RPMS/' + nvr)
197 else:
198 files.append(self.tree.basedir + '/' + arch + '/RPMS/' + nvr)
d38e8382
ER
199 return files
200
f6dff636
ER
201 def obsoletes(self):
202 """
203 Return obsoletes for all packages in Pkg:
204
205 {'php-geshi': set(['geshi'])}
206
207 """
208 def rpmhdr(pkg):
209 ts = rpm.ts()
7e39d1d1 210 ts.setVSFlags(rpm.RPMVSF_NODSAHEADER)
f6dff636
ER
211 fdno = os.open(pkg, os.O_RDONLY)
212 hdr = ts.hdrFromFdno(fdno)
213 os.close(fdno)
214 return hdr
215
216 obsoletes = {}
217 for rpmfile in self.rpmfiles():
c5d9d3e9
AM
218 if not os.path.exists(rpmfile):
219 continue
f6dff636
ER
220 hdr = rpmhdr(rpmfile)
221 if not hdr[rpm.RPMTAG_OBSOLETES]:
222 continue
223
224 name = hdr[rpm.RPMTAG_NAME]
225 if not name in obsoletes:
226 obsoletes[name] = set()
227
228 for tag in hdr[rpm.RPMTAG_OBSOLETES]:
229 obsoletes[name].add(tag)
230
231 return obsoletes
232
213a164a 233 def move(self, dsttree, test = False):
85f3481a 234 if dsttree.has_key(self.nvr):
8911f226 235 movedany = False
151d31cc 236 for arch in self.files.keys():
85f3481a 237 if arch in dsttree[self.nvr].files.keys():
77b616ee
AM
238 msg = ""
239 if test:
240 msg = "TEST "
241 pinfo("%sArch %s for %s is already present in dest tree; removing from srctree" % (msg, arch, self.nvr))
151d31cc 242 for rpm in self.files[arch]:
930417e0 243 if self.is_debuginfo(rpm):
54ff0049
ER
244 rm(self.tree.basedir + '/' + arch + '/debuginfo/' + rpm, test)
245 else:
246 rm(self.tree.basedir + '/' + arch + '/RPMS/' + rpm, test)
098f4a50 247 else:
8911f226
ER
248 movedany = True
249 dsttree[self.nvr].files[arch] = self.files[arch]
151d31cc 250 for rpm in self.files[arch]:
54ff0049
ER
251 if self.is_debuginfo(rpm):
252 mv(self.tree.basedir + '/' + arch + '/debuginfo/' + rpm, dsttree.basedir + '/' + arch + '/debuginfo/', test)
253 else:
254 mv(self.tree.basedir + '/' + arch + '/RPMS/' + rpm, dsttree.basedir + '/' + arch + '/RPMS/', test)
213a164a 255 if not test and movedany:
098f4a50 256 for bid in self.build.keys():
8911f226 257 dsttree[self.nvr].build[bid] = self.build[bid]
85f3481a 258 dsttree[self.nvr].writeinfo()
8911f226 259 rm(self.tree.basedir + '/SRPMS/.metadata/' + self.nvr + '.src.rpm.info', test)
098f4a50 260 else:
8911f226 261 # move files
151d31cc
MM
262 for arch in self.files.keys():
263 for rpm in self.files[arch]:
54ff0049
ER
264 if self.is_debuginfo(rpm):
265 mv(self.tree.basedir + '/' + arch + '/debuginfo/' + rpm, dsttree.basedir + '/' + arch + '/debuginfo/', test)
266 else:
267 mv(self.tree.basedir + '/' + arch + '/RPMS/' + rpm, dsttree.basedir + '/' + arch + '/RPMS/', test)
098f4a50 268
8911f226
ER
269 # move metadata
270 mv(self.tree.basedir + '/SRPMS/.metadata/' + self.nvr + '.src.rpm.info', dsttree.basedir + '/SRPMS/.metadata/', test)
098f4a50 271
151d31cc 272class FtpTree(BaseFtpTree):
098f4a50 273 def __init__(self, tree, loadall=False):
151d31cc 274 BaseFtpTree.__init__(self, tree)
8911f226
ER
275 self.loadedpkgs = {}
276 self.marked4removal = []
277 self.marked4moving = []
278 self.pkgnames = []
d9b3388c 279 self.__loadpkgnames()
098f4a50 280 if loadall:
d9b3388c 281 for pkgname in self.pkgnames:
8911f226 282 self.loadedpkgs[pkgname] = Pkg(pkgname, self)
796b7867 283 # Tests:
8911f226 284 self.do_checkbuild = True
d9b3388c 285
098f4a50 286 def __getitem__(self, key):
b353ff36 287 if key in self.loadedpkgs:
098f4a50
MM
288 return self.loadedpkgs[key]
289 elif key in self.pkgnames:
290 pkg=Pkg(key, self)
291 self.loadedpkgs[key]=pkg
292 return pkg
293 else:
9c170c61 294 raise KeyError(key)
796b7867 295
098f4a50
MM
296 def has_key(self, key):
297 if key in self.pkgnames:
298 return True
299 else:
300 return False
796b7867 301
1610d209
MM
302 def keys(self):
303 return self.pkgnames
098f4a50 304
d7667ebe
MM
305 def values(self):
306 return self.loadedpkgs.values()
307
67b5bf38 308 def checktree(self, dsttree):
85f3481a
MM
309 self.__checkbuild(self.loadedpkgs.values())
310 self.__checkarchs(dsttree, self.loadedpkgs.values())
0767aa5e 311
213a164a 312 def testmove(self, dsttree, archivetree = None):
67b5bf38
MM
313 self.__checkbuild(self.marked4moving)
314 self.__checkarchs(dsttree, self.marked4moving)
b28d4942 315 if not dsttree.treename.count("archive"):
0d81c563 316 self.__checkduplicates(self.marked4moving)
aeb928ae
ER
317
318 self.__checksigns(dsttree, self.marked4moving, test = True)
f6dff636 319 self.__checkforobsoletes(dsttree, self.marked4moving, test = True)
8643d2cd 320 self.__checkforrelease(dsttree, self.marked4moving, test = True)
461bc5ab 321
de015730
ER
322 if not self.treename.count("archive"):
323 self.__rmolderfromsrc(test = True)
b28d4942 324 if not dsttree.treename.count("archive"):
0d81c563 325 self.__rmotherfromdst(dsttree, test = True, archivetree = archivetree)
a0b52be0
AM
326
327 for pkg in self.marked4moving:
8911f226 328 pkg.move(dsttree, test = True)
9b00920e 329
213a164a 330 def movepkgs(self, dsttree, archivetree = None):
f053c9e6 331 if self.do_checkbuild:
67b5bf38 332 self.__checkbuild(self.marked4moving)
098f4a50 333 bailoutonerror()
aeb928ae 334
67b5bf38 335 self.__checkarchs(dsttree, self.marked4moving)
098f4a50 336 bailoutonerror()
aeb928ae
ER
337
338 self.__checksigns(dsttree, self.marked4moving)
339 bailoutonerror()
340
de015730
ER
341 if not self.treename.count("archive"):
342 self.__rmolderfromsrc()
b28d4942 343 if not dsttree.treename.count("archive"):
0d81c563 344 self.__rmotherfromdst(dsttree, archivetree = archivetree)
098f4a50 345
14085d11 346 for pkg in self.marked4moving:
098f4a50
MM
347 pkg.move(dsttree)
348
ef010074 349 def rpmfiles(self, debugfiles = True, sourcefiles = True):
d38e8382
ER
350 if self.do_checkbuild:
351 self.__checkbuild(self.marked4moving)
d38e8382
ER
352
353 files = []
354 for pkg in self.marked4moving:
ef010074 355 files += pkg.rpmfiles(debugfiles = debugfiles, sourcefiles = sourcefiles)
d38e8382
ER
356 return files
357
14085d11 358 def removepkgs(self):
6bc6286e 359 if self.do_checkbuild:
67b5bf38 360 self.__checkbuild(self.marked4removal)
6bc6286e 361 bailoutonerror()
14085d11
MM
362 for pkg in self.marked4removal:
363 pkg.remove()
364
365 def mark4removal(self, wannabepkgs):
366 self.__mark4something(wannabepkgs, Pkg.mark4removal)
367
368 def mark4moving(self, wannabepkgs):
369 self.__mark4something(wannabepkgs, Pkg.mark4moving)
14085d11
MM
370
371 # Internal functions below
b36df4e6
ER
372 def __arch_stringify(self, list):
373 ret = []
d886975e 374 dist = config.ftp_dist;
b36df4e6
ER
375 for arch in list:
376 ret.append(dist + '-' + arch)
377 return ' '.join(ret)
14085d11 378
d9b3388c
MM
379 def __loadpkgnames(self):
380 def checkfiletype(name):
381 if name[-13:]=='.src.rpm.info':
382 return True
383 else:
384 return False
f09b8024
JR
385 pkglist = list(filter(checkfiletype, os.listdir(self.basedir+'/SRPMS/.metadata')))
386 self.pkgnames = list(map((lambda x: x[:-13]), pkglist))
d9b3388c 387
14085d11 388 def __mark4something(self, wannabepkgs, markfunction):
098f4a50 389 def chopoffextension(pkg):
f6dff636
ER
390 found = pkg.find('.src.rpm')
391 if found == -1:
098f4a50
MM
392 return pkg
393 else:
394 return pkg[:found]
f6dff636 395
098f4a50 396 for wannabepkg in wannabepkgs:
f6dff636 397 pkgname = chopoffextension(wannabepkg)
098f4a50
MM
398 if pkgname in self.pkgnames:
399 if not pkgname in self.loadedpkgs.keys():
400 self.loadedpkgs[pkgname]=Pkg(pkgname, self)
14085d11 401 markfunction(self.loadedpkgs[pkgname])
098f4a50 402 else:
492b6398 403 perror('%s not found in source tree' % pkgname)
098f4a50 404 bailoutonerror()
14085d11 405
67b5bf38 406 def __checkbuild(self, marked):
8911f226
ER
407 """
408 Checks queue file if all arches are built
409
410 Reads config.builderqueue to grab the info
411 """
3943100f 412 f = urlmess.urlopen(config.builderqueue)
8911f226
ER
413 requests = {}
414 reid = re.compile(r'^.*id=(.*) pri.*$')
415 regb = re.compile(r'^group:.*$|builders:.*$', re.M)
3943100f 416 for i in re.findall(regb, f.read().decode('utf-8')):
8911f226
ER
417 if i[0] == 'g':
418 id = reid.sub(r'\1', i)
419 requests[id] = ""
098f4a50 420 elif i[0]=='b':
8911f226 421 requests[id] = requests[id] + i
098f4a50 422 f.close()
8911f226 423
6bc6286e 424 for pkg in marked:
098f4a50 425 for bid in pkg.build.keys():
b353ff36 426 if bid in requests and not requests[bid].find('?') == -1:
85f3481a 427 pkg.error("(buildid %s) building not finished" % bid)
098f4a50 428
67b5bf38 429 def __checkarchs(self, dsttree, marked):
8911f226
ER
430 """
431 Checks marked pkgs it is built on all archs.
432 """
67b5bf38 433 for pkg in marked:
4802753d 434 if len(pkg.files.keys()) <= 1:
85f3481a 435 pkg.error('has only src.rpm built')
4802753d 436 continue
8911f226
ER
437 otherpkgnames = self.__find_other_pkgs(pkg, dsttree)
438
439 # check if we're not removing some archs
440 if otherpkgnames:
441 curarchs = []
442 missingarchs = []
796b7867
MM
443 for somepkg in otherpkgnames:
444 curarchs.extend(Pkg(somepkg, dsttree).files.keys())
445 for arch in curarchs:
446 if arch not in pkg.files.keys():
447 missingarchs.append(arch)
448 if missingarchs:
b36df4e6 449 pkg.error('moving would remove archs: %s' % self.__arch_stringify(missingarchs))
8911f226
ER
450 else:
451 # warn if a package isn't built for all archs
470814f3
JR
452 # ftp_archs + SRPMS
453 ftp_archs_num = len(config.ftp_archs) + 1
454 if (config.separate_noarch and 'noarch' in pkg.files.keys()):
455 # ftp_archs + SRPMS + noarch subpackages
456 ftp_archs_num += 1
457 # plain simple noarch package
458 if (len(pkg.files.keys()) == 2):
459 continue
460
461 if len(pkg.files.keys()) != ftp_archs_num:
8911f226 462 missingarchs = []
492b6398
MM
463 for arch in config.ftp_archs:
464 if arch not in pkg.files.keys():
465 missingarchs.append(arch)
b36df4e6 466 pkg.warning('not built for archs: %s' % self.__arch_stringify(missingarchs))
098f4a50 467
332719f8 468 def __checkduplicates(self, marked):
d97ec2d6
AM
469 """
470 Checks if marked packages contain duplicate packages (with different versions)
471 """
472 for pkg in marked:
473 olderpkgnames = self.__find_older_pkgs(pkg)
474 for i in olderpkgnames:
406223be
AM
475 markednames = [str(x) for x in marked]
476 if i in markednames:
477 pkg.error('duplicate package: %s' % i)
d97ec2d6 478
213a164a 479 def __rmolderfromsrc(self, test = False):
14085d11 480 for pkg in self.marked4moving:
8911f226 481 olderpkgnames = self.__find_older_pkgs(pkg)
098f4a50 482 for i in olderpkgnames:
a0b52be0 483 Pkg(i, self).remove(test)
098f4a50 484
213a164a 485 def __rmotherfromdst(self, dsttree, test = False, archivetree = None):
14085d11 486 for pkg in self.marked4moving:
8911f226 487 pkgnames = self.__find_other_pkgs(pkg, dsttree)
098f4a50 488 for i in pkgnames:
213a164a
ER
489 if archivetree == None:
490 Pkg(i, dsttree).remove(test)
491 else:
492 Pkg(i, dsttree).move(archivetree, test = test)
098f4a50 493
d9b3388c 494 # Used more than once filter functions
d9b3388c 495 def __find_other_pkgs(self, pkg, tree):
8911f226
ER
496 escapedpkgname = pkg.name.replace('.', '\.').replace('+', '\+')
497 ziewre = re.compile(escapedpkgname + '-[^-]*-[^-]*$')
d9b3388c 498 def filter_other_pkgs(x):
85f3481a 499 if ziewre.match(x) and not x == pkg.nvr:
d9b3388c
MM
500 return True
501 else:
502 return False
f09b8024 503 return list(filter(filter_other_pkgs, tree.pkgnames))
d9b3388c
MM
504
505 def __find_older_pkgs(self, pkg):
506 def filter_older_pkgs(x):
8911f226 507 c = x.split('-')
ce470bd9
MM
508 rc = rpm.labelCompare(('0', pkg.version, pkg.release),
509 ('0', c[-2], c[-1]))
510 if rc == 1: # pkg > x
d9b3388c 511 return True
d9b3388c
MM
512 else:
513 return False
f09b8024 514 return list(filter(filter_older_pkgs, self.__find_other_pkgs(pkg, self)))
d9b3388c 515
aeb928ae
ER
516 def __checksigns(self, tree, pkgs, test = False):
517 """
518 Checks if pkgs in tree are all signed.
519
520 in case of test = true, error flag is set for unsigned packages
521 """
522 if not tree.treename in config.signed_trees:
523 return
524
525 for pkg in pkgs:
526 unsigned = 0
527 for file in pkg.rpmfiles():
528 if not is_signed(file):
f6dff636 529 unsigned += 1
aeb928ae
ER
530
531 if unsigned != 0:
532 if test == True:
533 if not quietmode:
534 pkg.warning('%d files not signed' % unsigned)
535 else:
536 pkg.error('%d files not signed' % unsigned)
f6dff636
ER
537
538 def __checkforobsoletes(self, tree, pkgs, test = False):
539 """
540 Checks queue file if package obsoletes something in destination tree and suggest for removal.
541
542 Only NAME tag is compared, i.e virtual packages do not get reported.
543
544 """
545 if test != True:
546 return
547
548 def findbyname(name):
549 def x(nvr):
550 return '-'.join(nvr.split('-')[:-2]) == name
f09b8024 551 return list(filter(x, tree.pkgnames))
f6dff636
ER
552
553 for pkg in pkgs:
554 obsoletes = pkg.obsoletes()
555 if not obsoletes:
556 continue
557
558 for pn, setlist in obsoletes.items():
559 for item in setlist:
560 p = findbyname(item)
561 if p:
9e047859 562 pkg.warning('obsoletes %s (via %s) in dest tree, perhaps you want rmpkg' % (p,pn))
8643d2cd
ER
563
564 def __checkforrelease(self, tree, pkgs, test = False):
565 """
566 Checks queue file if package release is non integer.
567
568 """
569 if test != True:
570 return
571
572 for pkg in pkgs:
573 if not pkg.is_release():
574 pkg.warning('non-integer release: %s' % pkg.release)
This page took 0.465918 seconds and 4 git commands to generate.