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