blob: 5432ffcf2ca30bffc3e4c9aa48b9a1455df2e132 [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)
386 for tok, path in path_tokens:
387 if dest.startswith(path):
388 dest = ('{%s}' % tok) + dest[len(path):]
389 files = [('/ '.join(src.rsplit('/', 1)), dest)
390 for src in srcs]
391 data['resources'].extend(files)
392 continue
393 # 2.2 package_data -> extra_files
394 package_dirs = dist.package_dir or {}
395 for package, extras in iter(dist.package_data.items()) or []:
396 package_dir = package_dirs.get(package, package)
397 files = [os.path.join(package_dir, f) for f in extras]
398 data['extra_files'].extend(files)
399
400 # Use README file if its content is the desciption
401 if "description" in data:
402 ref = md5(re.sub('\s', '',
403 self.data['description']).lower().encode())
404 ref = ref.digest()
405 for readme in glob.glob('README*'):
Victor Stinner35de5ac2011-05-19 15:09:57 +0200406 with open(readme, encoding='utf-8') as fp:
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200407 contents = fp.read()
Victor Stinner35de5ac2011-05-19 15:09:57 +0200408 contents = re.sub('\s', '', contents.lower()).encode()
409 val = md5(contents).digest()
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200410 if val == ref:
411 del data['description']
412 data['description-file'] = readme
413 break
414
415 # apply monkey patch to distutils (v1) and setuptools (if needed)
416 # (abort the feature if distutils v1 has been killed)
417 try:
418 from distutils import core
419 core.setup # make sure it's not d2 maskerading as d1
420 except (ImportError, AttributeError):
421 return
422 saved_setups = [(core, core.setup)]
423 core.setup = setup_mock
424 try:
425 import setuptools
426 except ImportError:
427 pass
428 else:
429 saved_setups.append((setuptools, setuptools.setup))
430 setuptools.setup = setup_mock
431 # get metadata by executing the setup.py with the patched setup(...)
432 success = False # for python < 2.4
433 try:
434 load_setup()
435 success = True
436 finally: # revert monkey patches
437 for patched_module, original_setup in saved_setups:
438 patched_module.setup = original_setup
439 if not self.data:
440 raise ValueError('Unable to load metadata from setup.py')
441 return success
442
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200443 def inspect(self):
444 """Inspect the current working diretory for a name and version.
445
446 This information is harvested in where the directory is named
447 like [name]-[version].
448 """
449 dir_name = os.path.basename(os.getcwd())
450 self.data['name'] = dir_name
451 match = re.match(r'(.*)-(\d.+)', dir_name)
452 if match:
453 self.data['name'] = match.group(1)
454 self.data['version'] = match.group(2)
455 # TODO Needs tested!
456 if not is_valid_version(self.data['version']):
457 msg = "Invalid version discovered: %s" % self.data['version']
458 raise RuntimeError(msg)
459
460 def query_user(self):
461 self.data['name'] = ask('Project name', self.data['name'],
462 _helptext['name'])
463
464 self.data['version'] = ask('Current version number',
465 self.data.get('version'), _helptext['version'])
466 self.data['summary'] = ask('Package summary',
467 self.data.get('summary'), _helptext['summary'],
468 lengthy=True)
469 self.data['author'] = ask('Author name',
470 self.data.get('author'), _helptext['author'])
471 self.data['author_email'] = ask('Author e-mail address',
472 self.data.get('author_email'), _helptext['author_email'])
473 self.data['home_page'] = ask('Project Home Page',
474 self.data.get('home_page'), _helptext['home_page'],
475 required=False)
476
477 if ask_yn('Do you want me to automatically build the file list '
478 'with everything I can find in the current directory ? '
479 'If you say no, you will have to define them manually.') == 'y':
480 self._find_files()
481 else:
482 while ask_yn('Do you want to add a single module ?'
483 ' (you will be able to add full packages next)',
484 helptext=_helptext['modules']) == 'y':
485 self._set_multi('Module name', 'modules')
486
487 while ask_yn('Do you want to add a package ?',
488 helptext=_helptext['packages']) == 'y':
489 self._set_multi('Package name', 'packages')
490
491 while ask_yn('Do you want to add an extra file ?',
492 helptext=_helptext['extra_files']) == 'y':
493 self._set_multi('Extra file/dir name', 'extra_files')
494
495 if ask_yn('Do you want to set Trove classifiers?',
496 helptext=_helptext['do_classifier']) == 'y':
497 self.set_classifier()
498
499 def _find_files(self):
500 # we are looking for python modules and packages,
501 # other stuff are added as regular files
502 pkgs = self.data['packages']
503 modules = self.data['modules']
504 extra_files = self.data['extra_files']
505
506 def is_package(path):
507 return os.path.exists(os.path.join(path, '__init__.py'))
508
509 curdir = os.getcwd()
510 scanned = []
511 _pref = ['lib', 'include', 'dist', 'build', '.', '~']
512 _suf = ['.pyc']
513
514 def to_skip(path):
515 path = relative(path)
516
517 for pref in _pref:
518 if path.startswith(pref):
519 return True
520
521 for suf in _suf:
522 if path.endswith(suf):
523 return True
524
525 return False
526
527 def relative(path):
528 return path[len(curdir) + 1:]
529
530 def dotted(path):
531 res = relative(path).replace(os.path.sep, '.')
532 if res.endswith('.py'):
533 res = res[:-len('.py')]
534 return res
535
536 # first pass: packages
537 for root, dirs, files in os.walk(curdir):
538 if to_skip(root):
539 continue
540 for dir_ in sorted(dirs):
541 if to_skip(dir_):
542 continue
543 fullpath = os.path.join(root, dir_)
544 dotted_name = dotted(fullpath)
545 if is_package(fullpath) and dotted_name not in pkgs:
546 pkgs.append(dotted_name)
547 scanned.append(fullpath)
548
549 # modules and extra files
550 for root, dirs, files in os.walk(curdir):
551 if to_skip(root):
552 continue
553
554 if any(root.startswith(path) for path in scanned):
555 continue
556
557 for file in sorted(files):
558 fullpath = os.path.join(root, file)
559 if to_skip(fullpath):
560 continue
561 # single module?
562 if os.path.splitext(file)[-1] == '.py':
563 modules.append(dotted(fullpath))
564 else:
565 extra_files.append(relative(fullpath))
566
567 def _set_multi(self, question, name):
568 existing_values = self.data[name]
569 value = ask(question, helptext=_helptext[name]).strip()
570 if value not in existing_values:
571 existing_values.append(value)
572
573 def set_classifier(self):
574 self.set_maturity_status(self.classifiers)
575 self.set_license(self.classifiers)
576 self.set_other_classifier(self.classifiers)
577
578 def set_other_classifier(self, classifiers):
579 if ask_yn('Do you want to set other trove identifiers', 'n',
580 _helptext['trove_generic']) != 'y':
581 return
582 self.walk_classifiers(classifiers, [CLASSIFIERS], '')
583
584 def walk_classifiers(self, classifiers, trovepath, desc):
585 trove = trovepath[-1]
586
587 if not trove:
588 return
589
590 for key in sorted(trove):
591 if len(trove[key]) == 0:
592 if ask_yn('Add "%s"' % desc[4:] + ' :: ' + key, 'n') == 'y':
593 classifiers.add(desc[4:] + ' :: ' + key)
594 continue
595
596 if ask_yn('Do you want to set items under\n "%s" (%d sub-items)'
597 % (key, len(trove[key])), 'n',
598 _helptext['trove_generic']) == 'y':
599 self.walk_classifiers(classifiers, trovepath + [trove[key]],
600 desc + ' :: ' + key)
601
602 def set_license(self, classifiers):
603 while True:
604 license = ask('What license do you use',
605 helptext=_helptext['trove_license'], required=False)
606 if not license:
607 return
608
609 license_words = license.lower().split(' ')
610 found_list = []
611
612 for index, licence in LICENCES:
613 for word in license_words:
614 if word in licence:
615 found_list.append(index)
616 break
617
618 if len(found_list) == 0:
619 print('ERROR: Could not find a matching license for "%s"' %
620 license)
621 continue
622
623 question = 'Matching licenses:\n\n'
624
625 for index, list_index in enumerate(found_list):
626 question += ' %s) %s\n' % (index + 1,
627 _CLASSIFIERS_LIST[list_index])
628
629 question += ('\nType the number of the license you wish to use or '
630 '? to try again:')
631 choice = ask(question, required=False)
632
633 if choice == '?':
634 continue
635 if choice == '':
636 return
637
638 try:
639 index = found_list[int(choice) - 1]
640 except ValueError:
641 print("ERROR: Invalid selection, type a number from the list "
642 "above.")
643
644 classifiers.add(_CLASSIFIERS_LIST[index])
645
646 def set_maturity_status(self, classifiers):
647 maturity_name = lambda mat: mat.split('- ')[-1]
648 maturity_question = '''\
649 Please select the project status:
650
651 %s
652
653 Status''' % '\n'.join('%s - %s' % (i, maturity_name(n))
654 for i, n in enumerate(PROJECT_MATURITY))
655 while True:
656 choice = ask(dedent(maturity_question), required=False)
657
658 if choice:
659 try:
660 choice = int(choice) - 1
661 key = PROJECT_MATURITY[choice]
662 classifiers.add(key)
663 return
664 except (IndexError, ValueError):
665 print("ERROR: Invalid selection, type a single digit "
666 "number.")
667
668
669def main():
670 """Main entry point."""
671 program = MainProgram()
672 # # uncomment when implemented
673 # if not program.load_existing_setup_script():
674 # program.inspect_directory()
675 # program.query_user()
676 # program.update_config_file()
677 # program.write_setup_script()
678 # packaging.util.cfg_to_args()
679 program()
680
681
682if __name__ == '__main__':
683 main()