blob: 5a2eabdb3dad27ba5076679da78b73dfafec71e9 [file] [log] [blame]
Tarek Ziade1231a4e2011-05-19 13:07:25 +02001"""Interactive helper used to create a setup.cfg file.
2
3This script will generate a packaging configuration file by looking at
4the current directory and asking the user questions. It is intended to
Éric Araujo35a4d012011-06-04 22:24:59 +02005be called as *pysetup create*.
Tarek Ziade1231a4e2011-05-19 13:07:25 +02006"""
7
8# Original code by Sean Reifschneider <jafo@tummy.com>
9
10# Original TODO list:
11# Look for a license file and automatically add the category.
12# When a .c file is found during the walk, can we add it as an extension?
13# Ask if there is a maintainer different that the author
14# Ask for the platform (can we detect this via "import win32" or something?)
15# Ask for the dependencies.
16# Ask for the Requires-Dist
17# Ask for the Provides-Dist
18# Ask for a description
19# Detect scripts (not sure how. #! outside of package?)
20
21import os
Éric Araujo35a4d012011-06-04 22:24:59 +020022import re
Tarek Ziade1231a4e2011-05-19 13:07:25 +020023import imp
24import sys
25import glob
Tarek Ziade1231a4e2011-05-19 13:07:25 +020026import shutil
27import sysconfig
Victor Stinner9cf6d132011-05-19 21:42:47 +020028import tokenize
Tarek Ziade1231a4e2011-05-19 13:07:25 +020029from hashlib import md5
Éric Araujo35a4d012011-06-04 22:24:59 +020030from textwrap import dedent
Tarek Ziade1231a4e2011-05-19 13:07:25 +020031from functools import cmp_to_key
Éric Araujo35a4d012011-06-04 22:24:59 +020032from configparser import RawConfigParser
Tarek Ziade1231a4e2011-05-19 13:07:25 +020033# importing this with an underscore as it should be replaced by the
34# dict form or another structures for all purposes
35from packaging._trove import all_classifiers as _CLASSIFIERS_LIST
36from packaging.version import is_valid_version
37
38_FILENAME = 'setup.cfg'
39_DEFAULT_CFG = '.pypkgcreate'
40
41_helptext = {
42 'name': '''
43The name of the program to be packaged, usually a single word composed
44of lower-case characters such as "python", "sqlalchemy", or "CherryPy".
45''',
46 'version': '''
47Version number of the software, typically 2 or 3 numbers separated by dots
48such as "1.00", "0.6", or "3.02.01". "0.1.0" is recommended for initial
49development.
50''',
51 'summary': '''
52A one-line summary of what this project is or does, typically a sentence 80
53characters or less in length.
54''',
55 'author': '''
56The full name of the author (typically you).
57''',
58 'author_email': '''
59E-mail address of the project author (typically you).
60''',
61 'do_classifier': '''
62Trove classifiers are optional identifiers that allow you to specify the
63intended audience by saying things like "Beta software with a text UI
64for Linux under the PSF license. However, this can be a somewhat involved
65process.
66''',
67 'packages': '''
68You can provide a package name contained in your project.
69''',
70 'modules': '''
71You can provide a python module contained in your project.
72''',
73 'extra_files': '''
74You can provide extra files/dirs contained in your project.
75It has to follow the template syntax. XXX add help here.
76''',
77
78 'home_page': '''
79The home page for the project, typically starting with "http://".
80''',
81 'trove_license': '''
82Optionally you can specify a license. Type a string that identifies a common
83license, and then you can select a list of license specifiers.
84''',
85 'trove_generic': '''
86Optionally, you can set other trove identifiers for things such as the
87human language, programming language, user interface, etc...
88''',
89 'setup.py found': '''
90The setup.py script will be executed to retrieve the metadata.
91A wizard will be run if you answer "n",
92''',
93}
94
95PROJECT_MATURITY = ['Development Status :: 1 - Planning',
96 'Development Status :: 2 - Pre-Alpha',
97 'Development Status :: 3 - Alpha',
98 'Development Status :: 4 - Beta',
99 'Development Status :: 5 - Production/Stable',
100 'Development Status :: 6 - Mature',
101 'Development Status :: 7 - Inactive']
102
103# XXX everything needs docstrings and tests (both low-level tests of various
104# methods and functional tests of running the script)
105
106
107def load_setup():
108 """run the setup script (i.e the setup.py file)
109
110 This function load the setup file in all cases (even if it have already
111 been loaded before, because we are monkey patching its setup function with
112 a particular one"""
Victor Stinner9cf6d132011-05-19 21:42:47 +0200113 with open("setup.py", "rb") as f:
114 encoding, lines = tokenize.detect_encoding(f.readline)
115 with open("setup.py", encoding=encoding) as f:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200116 imp.load_module("setup", f, "setup.py", (".py", "r", imp.PY_SOURCE))
117
118
119def ask_yn(question, default=None, helptext=None):
120 question += ' (y/n)'
121 while True:
122 answer = ask(question, default, helptext, required=True)
123 if answer and answer[0].lower() in 'yn':
124 return answer[0].lower()
125
126 print('\nERROR: You must select "Y" or "N".\n')
127
128
129def ask(question, default=None, helptext=None, required=True,
130 lengthy=False, multiline=False):
131 prompt = '%s: ' % (question,)
132 if default:
133 prompt = '%s [%s]: ' % (question, default)
134 if default and len(question) + len(default) > 70:
135 prompt = '%s\n [%s]: ' % (question, default)
136 if lengthy or multiline:
137 prompt += '\n > '
138
139 if not helptext:
140 helptext = 'No additional help available.'
141
142 helptext = helptext.strip("\n")
143
144 while True:
145 sys.stdout.write(prompt)
146 sys.stdout.flush()
147
148 line = sys.stdin.readline().strip()
149 if line == '?':
150 print('=' * 70)
151 print(helptext)
152 print('=' * 70)
153 continue
154 if default and not line:
155 return default
156 if not line and required:
157 print('*' * 70)
158 print('This value cannot be empty.')
159 print('===========================')
160 if helptext:
161 print(helptext)
162 print('*' * 70)
163 continue
164 return line
165
166
167def convert_yn_to_bool(yn, yes=True, no=False):
168 """Convert a y/yes or n/no to a boolean value."""
169 if yn.lower().startswith('y'):
170 return yes
171 else:
172 return no
173
174
175def _build_classifiers_dict(classifiers):
176 d = {}
177 for key in classifiers:
178 subDict = d
179 for subkey in key.split(' :: '):
180 if not subkey in subDict:
181 subDict[subkey] = {}
182 subDict = subDict[subkey]
183 return d
184
185CLASSIFIERS = _build_classifiers_dict(_CLASSIFIERS_LIST)
186
187
188def _build_licences(classifiers):
189 res = []
190 for index, item in enumerate(classifiers):
191 if not item.startswith('License :: '):
192 continue
193 res.append((index, item.split(' :: ')[-1].lower()))
194 return res
195
196LICENCES = _build_licences(_CLASSIFIERS_LIST)
197
198
199class MainProgram:
200 """Make a project setup configuration file (setup.cfg)."""
201
202 def __init__(self):
203 self.configparser = None
204 self.classifiers = set()
205 self.data = {'name': '',
206 'version': '1.0.0',
207 'classifier': self.classifiers,
208 'packages': [],
209 'modules': [],
210 'platform': [],
211 'resources': [],
212 'extra_files': [],
213 'scripts': [],
214 }
215 self._load_defaults()
216
217 def __call__(self):
218 setupcfg_defined = False
219 if self.has_setup_py() and self._prompt_user_for_conversion():
220 setupcfg_defined = self.convert_py_to_cfg()
221 if not setupcfg_defined:
222 self.define_cfg_values()
223 self._write_cfg()
224
225 def has_setup_py(self):
Éric Araujo35a4d012011-06-04 22:24:59 +0200226 """Test for the existence of a setup.py file."""
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200227 return os.path.exists('setup.py')
228
229 def define_cfg_values(self):
230 self.inspect()
231 self.query_user()
232
233 def _lookup_option(self, key):
234 if not self.configparser.has_option('DEFAULT', key):
235 return None
236 return self.configparser.get('DEFAULT', key)
237
238 def _load_defaults(self):
239 # Load default values from a user configuration file
240 self.configparser = RawConfigParser()
241 # TODO replace with section in distutils config file
242 default_cfg = os.path.expanduser(os.path.join('~', _DEFAULT_CFG))
243 self.configparser.read(default_cfg)
244 self.data['author'] = self._lookup_option('author')
245 self.data['author_email'] = self._lookup_option('author_email')
246
247 def _prompt_user_for_conversion(self):
248 # Prompt the user about whether they would like to use the setup.py
249 # conversion utility to generate a setup.cfg or generate the setup.cfg
250 # from scratch
251 answer = ask_yn(('A legacy setup.py has been found.\n'
252 'Would you like to convert it to a setup.cfg?'),
253 default="y",
254 helptext=_helptext['setup.py found'])
255 return convert_yn_to_bool(answer)
256
257 def _dotted_packages(self, data):
258 packages = sorted(data)
259 modified_pkgs = []
260 for pkg in packages:
261 pkg = pkg.lstrip('./')
262 pkg = pkg.replace('/', '.')
263 modified_pkgs.append(pkg)
264 return modified_pkgs
265
266 def _write_cfg(self):
267 if os.path.exists(_FILENAME):
268 if os.path.exists('%s.old' % _FILENAME):
269 print("ERROR: %(name)s.old backup exists, please check that "
270 "current %(name)s is correct and remove %(name)s.old" %
271 {'name': _FILENAME})
272 return
273 shutil.move(_FILENAME, '%s.old' % _FILENAME)
274
Victor Stinnerdd13dd42011-05-19 18:45:32 +0200275 with open(_FILENAME, 'w', encoding='utf-8') as fp:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200276 fp.write('[metadata]\n')
277 # simple string entries
278 for name in ('name', 'version', 'summary', 'download_url'):
279 fp.write('%s = %s\n' % (name, self.data.get(name, 'UNKNOWN')))
280 # optional string entries
281 if 'keywords' in self.data and self.data['keywords']:
282 fp.write('keywords = %s\n' % ' '.join(self.data['keywords']))
283 for name in ('home_page', 'author', 'author_email',
284 'maintainer', 'maintainer_email', 'description-file'):
285 if name in self.data and self.data[name]:
286 fp.write('%s = %s\n' % (name, self.data[name]))
287 if 'description' in self.data:
288 fp.write(
289 'description = %s\n'
290 % '\n |'.join(self.data['description'].split('\n')))
291 # multiple use string entries
292 for name in ('platform', 'supported-platform', 'classifier',
293 'requires-dist', 'provides-dist', 'obsoletes-dist',
294 'requires-external'):
295 if not(name in self.data and self.data[name]):
296 continue
297 fp.write('%s = ' % name)
298 fp.write(''.join(' %s\n' % val
299 for val in self.data[name]).lstrip())
300 fp.write('\n[files]\n')
301 for name in ('packages', 'modules', 'scripts',
302 'package_data', 'extra_files'):
303 if not(name in self.data and self.data[name]):
304 continue
305 fp.write('%s = %s\n'
306 % (name, '\n '.join(self.data[name]).strip()))
307 fp.write('\nresources =\n')
308 for src, dest in self.data['resources']:
309 fp.write(' %s = %s\n' % (src, dest))
310 fp.write('\n')
311
312 os.chmod(_FILENAME, 0o644)
313 print('Wrote "%s".' % _FILENAME)
314
315 def convert_py_to_cfg(self):
316 """Generate a setup.cfg from an existing setup.py.
317
318 It only exports the distutils metadata (setuptools specific metadata
319 is not currently supported).
320 """
321 data = self.data
322
323 def setup_mock(**attrs):
324 """Mock the setup(**attrs) in order to retrieve metadata."""
325 # use the distutils v1 processings to correctly parse metadata.
326 #XXX we could also use the setuptools distibution ???
327 from distutils.dist import Distribution
328 dist = Distribution(attrs)
329 dist.parse_config_files()
330
331 # 1. retrieve metadata fields that are quite similar in
332 # PEP 314 and PEP 345
333 labels = (('name',) * 2,
334 ('version',) * 2,
335 ('author',) * 2,
336 ('author_email',) * 2,
337 ('maintainer',) * 2,
338 ('maintainer_email',) * 2,
339 ('description', 'summary'),
340 ('long_description', 'description'),
341 ('url', 'home_page'),
342 ('platforms', 'platform'),
343 # backport only for 2.5+
344 ('provides', 'provides-dist'),
345 ('obsoletes', 'obsoletes-dist'),
346 ('requires', 'requires-dist'))
347
348 get = lambda lab: getattr(dist.metadata, lab.replace('-', '_'))
349 data.update((new, get(old)) for old, new in labels if get(old))
350
351 # 2. retrieve data that requires special processing
352 data['classifier'].update(dist.get_classifiers() or [])
353 data['scripts'].extend(dist.scripts or [])
354 data['packages'].extend(dist.packages or [])
355 data['modules'].extend(dist.py_modules or [])
356 # 2.1 data_files -> resources
357 if dist.data_files:
358 if len(dist.data_files) < 2 or \
359 isinstance(dist.data_files[1], str):
360 dist.data_files = [('', dist.data_files)]
361 # add tokens in the destination paths
362 vars = {'distribution.name': data['name']}
363 path_tokens = list(sysconfig.get_paths(vars=vars).items())
364
365 def length_comparison(x, y):
366 len_x = len(x[1])
367 len_y = len(y[1])
368 if len_x == len_y:
369 return 0
370 elif len_x < len_y:
371 return -1
372 else:
373 return 1
374
375 # sort tokens to use the longest one first
376 path_tokens.sort(key=cmp_to_key(length_comparison))
377 for dest, srcs in (dist.data_files or []):
378 dest = os.path.join(sys.prefix, dest)
Tarek Ziade2db56742011-05-21 14:24:14 +0200379 dest = dest.replace(os.path.sep, '/')
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200380 for tok, path in path_tokens:
Tarek Ziade2db56742011-05-21 14:24:14 +0200381 path = path.replace(os.path.sep, '/')
382 if not dest.startswith(path):
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200383 continue
Tarek Ziade2db56742011-05-21 14:24:14 +0200384
385 dest = ('{%s}' % tok) + dest[len(path):]
386 files = [('/ '.join(src.rsplit('/', 1)), dest)
387 for src in srcs]
388 data['resources'].extend(files)
389
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200390 # 2.2 package_data -> extra_files
391 package_dirs = dist.package_dir or {}
392 for package, extras in iter(dist.package_data.items()) or []:
393 package_dir = package_dirs.get(package, package)
Tarek Ziade2db56742011-05-21 14:24:14 +0200394 for file_ in extras:
395 if package_dir:
396 file_ = package_dir + '/' + file_
397 data['extra_files'].append(file_)
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200398
399 # Use README file if its content is the desciption
400 if "description" in data:
401 ref = md5(re.sub('\s', '',
402 self.data['description']).lower().encode())
403 ref = ref.digest()
404 for readme in glob.glob('README*'):
Victor Stinner35de5ac2011-05-19 15:09:57 +0200405 with open(readme, encoding='utf-8') as fp:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200406 contents = fp.read()
Victor Stinner35de5ac2011-05-19 15:09:57 +0200407 contents = re.sub('\s', '', contents.lower()).encode()
408 val = md5(contents).digest()
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200409 if val == ref:
410 del data['description']
411 data['description-file'] = readme
412 break
413
414 # apply monkey patch to distutils (v1) and setuptools (if needed)
415 # (abort the feature if distutils v1 has been killed)
416 try:
417 from distutils import core
418 core.setup # make sure it's not d2 maskerading as d1
419 except (ImportError, AttributeError):
420 return
421 saved_setups = [(core, core.setup)]
422 core.setup = setup_mock
423 try:
424 import setuptools
425 except ImportError:
426 pass
427 else:
428 saved_setups.append((setuptools, setuptools.setup))
429 setuptools.setup = setup_mock
430 # get metadata by executing the setup.py with the patched setup(...)
431 success = False # for python < 2.4
432 try:
433 load_setup()
434 success = True
435 finally: # revert monkey patches
436 for patched_module, original_setup in saved_setups:
437 patched_module.setup = original_setup
438 if not self.data:
439 raise ValueError('Unable to load metadata from setup.py')
440 return success
441
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200442 def inspect(self):
443 """Inspect the current working diretory for a name and version.
444
445 This information is harvested in where the directory is named
446 like [name]-[version].
447 """
448 dir_name = os.path.basename(os.getcwd())
449 self.data['name'] = dir_name
450 match = re.match(r'(.*)-(\d.+)', dir_name)
451 if match:
452 self.data['name'] = match.group(1)
453 self.data['version'] = match.group(2)
454 # TODO Needs tested!
455 if not is_valid_version(self.data['version']):
456 msg = "Invalid version discovered: %s" % self.data['version']
457 raise RuntimeError(msg)
458
459 def query_user(self):
460 self.data['name'] = ask('Project name', self.data['name'],
461 _helptext['name'])
462
463 self.data['version'] = ask('Current version number',
464 self.data.get('version'), _helptext['version'])
465 self.data['summary'] = ask('Package summary',
466 self.data.get('summary'), _helptext['summary'],
467 lengthy=True)
468 self.data['author'] = ask('Author name',
469 self.data.get('author'), _helptext['author'])
470 self.data['author_email'] = ask('Author e-mail address',
471 self.data.get('author_email'), _helptext['author_email'])
472 self.data['home_page'] = ask('Project Home Page',
473 self.data.get('home_page'), _helptext['home_page'],
474 required=False)
475
476 if ask_yn('Do you want me to automatically build the file list '
477 'with everything I can find in the current directory ? '
478 'If you say no, you will have to define them manually.') == 'y':
479 self._find_files()
480 else:
481 while ask_yn('Do you want to add a single module ?'
482 ' (you will be able to add full packages next)',
483 helptext=_helptext['modules']) == 'y':
484 self._set_multi('Module name', 'modules')
485
486 while ask_yn('Do you want to add a package ?',
487 helptext=_helptext['packages']) == 'y':
488 self._set_multi('Package name', 'packages')
489
490 while ask_yn('Do you want to add an extra file ?',
491 helptext=_helptext['extra_files']) == 'y':
492 self._set_multi('Extra file/dir name', 'extra_files')
493
494 if ask_yn('Do you want to set Trove classifiers?',
495 helptext=_helptext['do_classifier']) == 'y':
496 self.set_classifier()
497
498 def _find_files(self):
499 # we are looking for python modules and packages,
500 # other stuff are added as regular files
501 pkgs = self.data['packages']
502 modules = self.data['modules']
503 extra_files = self.data['extra_files']
504
505 def is_package(path):
506 return os.path.exists(os.path.join(path, '__init__.py'))
507
508 curdir = os.getcwd()
509 scanned = []
510 _pref = ['lib', 'include', 'dist', 'build', '.', '~']
511 _suf = ['.pyc']
512
513 def to_skip(path):
514 path = relative(path)
515
516 for pref in _pref:
517 if path.startswith(pref):
518 return True
519
520 for suf in _suf:
521 if path.endswith(suf):
522 return True
523
524 return False
525
526 def relative(path):
527 return path[len(curdir) + 1:]
528
529 def dotted(path):
530 res = relative(path).replace(os.path.sep, '.')
531 if res.endswith('.py'):
532 res = res[:-len('.py')]
533 return res
534
535 # first pass: packages
536 for root, dirs, files in os.walk(curdir):
537 if to_skip(root):
538 continue
539 for dir_ in sorted(dirs):
540 if to_skip(dir_):
541 continue
542 fullpath = os.path.join(root, dir_)
543 dotted_name = dotted(fullpath)
544 if is_package(fullpath) and dotted_name not in pkgs:
545 pkgs.append(dotted_name)
546 scanned.append(fullpath)
547
548 # modules and extra files
549 for root, dirs, files in os.walk(curdir):
550 if to_skip(root):
551 continue
552
553 if any(root.startswith(path) for path in scanned):
554 continue
555
556 for file in sorted(files):
557 fullpath = os.path.join(root, file)
558 if to_skip(fullpath):
559 continue
560 # single module?
561 if os.path.splitext(file)[-1] == '.py':
562 modules.append(dotted(fullpath))
563 else:
564 extra_files.append(relative(fullpath))
565
566 def _set_multi(self, question, name):
567 existing_values = self.data[name]
568 value = ask(question, helptext=_helptext[name]).strip()
569 if value not in existing_values:
570 existing_values.append(value)
571
572 def set_classifier(self):
573 self.set_maturity_status(self.classifiers)
574 self.set_license(self.classifiers)
575 self.set_other_classifier(self.classifiers)
576
577 def set_other_classifier(self, classifiers):
578 if ask_yn('Do you want to set other trove identifiers', 'n',
579 _helptext['trove_generic']) != 'y':
580 return
581 self.walk_classifiers(classifiers, [CLASSIFIERS], '')
582
583 def walk_classifiers(self, classifiers, trovepath, desc):
584 trove = trovepath[-1]
585
586 if not trove:
587 return
588
589 for key in sorted(trove):
590 if len(trove[key]) == 0:
591 if ask_yn('Add "%s"' % desc[4:] + ' :: ' + key, 'n') == 'y':
592 classifiers.add(desc[4:] + ' :: ' + key)
593 continue
594
595 if ask_yn('Do you want to set items under\n "%s" (%d sub-items)'
596 % (key, len(trove[key])), 'n',
597 _helptext['trove_generic']) == 'y':
598 self.walk_classifiers(classifiers, trovepath + [trove[key]],
599 desc + ' :: ' + key)
600
601 def set_license(self, classifiers):
602 while True:
603 license = ask('What license do you use',
604 helptext=_helptext['trove_license'], required=False)
605 if not license:
606 return
607
608 license_words = license.lower().split(' ')
609 found_list = []
610
611 for index, licence in LICENCES:
612 for word in license_words:
613 if word in licence:
614 found_list.append(index)
615 break
616
617 if len(found_list) == 0:
618 print('ERROR: Could not find a matching license for "%s"' %
619 license)
620 continue
621
622 question = 'Matching licenses:\n\n'
623
624 for index, list_index in enumerate(found_list):
625 question += ' %s) %s\n' % (index + 1,
626 _CLASSIFIERS_LIST[list_index])
627
628 question += ('\nType the number of the license you wish to use or '
629 '? to try again:')
630 choice = ask(question, required=False)
631
632 if choice == '?':
633 continue
634 if choice == '':
635 return
636
637 try:
638 index = found_list[int(choice) - 1]
639 except ValueError:
640 print("ERROR: Invalid selection, type a number from the list "
641 "above.")
642
643 classifiers.add(_CLASSIFIERS_LIST[index])
644
645 def set_maturity_status(self, classifiers):
646 maturity_name = lambda mat: mat.split('- ')[-1]
647 maturity_question = '''\
648 Please select the project status:
649
650 %s
651
652 Status''' % '\n'.join('%s - %s' % (i, maturity_name(n))
653 for i, n in enumerate(PROJECT_MATURITY))
654 while True:
655 choice = ask(dedent(maturity_question), required=False)
656
657 if choice:
658 try:
659 choice = int(choice) - 1
660 key = PROJECT_MATURITY[choice]
661 classifiers.add(key)
662 return
663 except (IndexError, ValueError):
664 print("ERROR: Invalid selection, type a single digit "
665 "number.")
666
667
668def main():
669 """Main entry point."""
670 program = MainProgram()
671 # # uncomment when implemented
672 # if not program.load_existing_setup_script():
673 # program.inspect_directory()
674 # program.query_user()
675 # program.update_config_file()
676 # program.write_setup_script()
677 # packaging.util.cfg_to_args()
678 program()
679
680
681if __name__ == '__main__':
682 main()