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