blob: ca827732d3704abb2c4c323ff075188306390121 [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
Tarek Ziade1231a4e2011-05-19 13:07:25 +0200440 def inspect(self):
441 """Inspect the current working diretory for a name and version.
442
443 This information is harvested in where the directory is named
444 like [name]-[version].
445 """
446 dir_name = os.path.basename(os.getcwd())
447 self.data['name'] = dir_name
448 match = re.match(r'(.*)-(\d.+)', dir_name)
449 if match:
450 self.data['name'] = match.group(1)
451 self.data['version'] = match.group(2)
452 # TODO Needs tested!
453 if not is_valid_version(self.data['version']):
454 msg = "Invalid version discovered: %s" % self.data['version']
455 raise RuntimeError(msg)
456
457 def query_user(self):
458 self.data['name'] = ask('Project name', self.data['name'],
459 _helptext['name'])
460
461 self.data['version'] = ask('Current version number',
462 self.data.get('version'), _helptext['version'])
463 self.data['summary'] = ask('Package summary',
464 self.data.get('summary'), _helptext['summary'],
465 lengthy=True)
466 self.data['author'] = ask('Author name',
467 self.data.get('author'), _helptext['author'])
468 self.data['author_email'] = ask('Author e-mail address',
469 self.data.get('author_email'), _helptext['author_email'])
470 self.data['home_page'] = ask('Project Home Page',
471 self.data.get('home_page'), _helptext['home_page'],
472 required=False)
473
474 if ask_yn('Do you want me to automatically build the file list '
475 'with everything I can find in the current directory ? '
476 'If you say no, you will have to define them manually.') == 'y':
477 self._find_files()
478 else:
479 while ask_yn('Do you want to add a single module ?'
480 ' (you will be able to add full packages next)',
481 helptext=_helptext['modules']) == 'y':
482 self._set_multi('Module name', 'modules')
483
484 while ask_yn('Do you want to add a package ?',
485 helptext=_helptext['packages']) == 'y':
486 self._set_multi('Package name', 'packages')
487
488 while ask_yn('Do you want to add an extra file ?',
489 helptext=_helptext['extra_files']) == 'y':
490 self._set_multi('Extra file/dir name', 'extra_files')
491
492 if ask_yn('Do you want to set Trove classifiers?',
493 helptext=_helptext['do_classifier']) == 'y':
494 self.set_classifier()
495
496 def _find_files(self):
497 # we are looking for python modules and packages,
498 # other stuff are added as regular files
499 pkgs = self.data['packages']
500 modules = self.data['modules']
501 extra_files = self.data['extra_files']
502
503 def is_package(path):
504 return os.path.exists(os.path.join(path, '__init__.py'))
505
506 curdir = os.getcwd()
507 scanned = []
508 _pref = ['lib', 'include', 'dist', 'build', '.', '~']
509 _suf = ['.pyc']
510
511 def to_skip(path):
512 path = relative(path)
513
514 for pref in _pref:
515 if path.startswith(pref):
516 return True
517
518 for suf in _suf:
519 if path.endswith(suf):
520 return True
521
522 return False
523
524 def relative(path):
525 return path[len(curdir) + 1:]
526
527 def dotted(path):
528 res = relative(path).replace(os.path.sep, '.')
529 if res.endswith('.py'):
530 res = res[:-len('.py')]
531 return res
532
533 # first pass: packages
534 for root, dirs, files in os.walk(curdir):
535 if to_skip(root):
536 continue
537 for dir_ in sorted(dirs):
538 if to_skip(dir_):
539 continue
540 fullpath = os.path.join(root, dir_)
541 dotted_name = dotted(fullpath)
542 if is_package(fullpath) and dotted_name not in pkgs:
543 pkgs.append(dotted_name)
544 scanned.append(fullpath)
545
546 # modules and extra files
547 for root, dirs, files in os.walk(curdir):
548 if to_skip(root):
549 continue
550
551 if any(root.startswith(path) for path in scanned):
552 continue
553
554 for file in sorted(files):
555 fullpath = os.path.join(root, file)
556 if to_skip(fullpath):
557 continue
558 # single module?
559 if os.path.splitext(file)[-1] == '.py':
560 modules.append(dotted(fullpath))
561 else:
562 extra_files.append(relative(fullpath))
563
564 def _set_multi(self, question, name):
565 existing_values = self.data[name]
566 value = ask(question, helptext=_helptext[name]).strip()
567 if value not in existing_values:
568 existing_values.append(value)
569
570 def set_classifier(self):
571 self.set_maturity_status(self.classifiers)
572 self.set_license(self.classifiers)
573 self.set_other_classifier(self.classifiers)
574
575 def set_other_classifier(self, classifiers):
576 if ask_yn('Do you want to set other trove identifiers', 'n',
577 _helptext['trove_generic']) != 'y':
578 return
579 self.walk_classifiers(classifiers, [CLASSIFIERS], '')
580
581 def walk_classifiers(self, classifiers, trovepath, desc):
582 trove = trovepath[-1]
583
584 if not trove:
585 return
586
587 for key in sorted(trove):
588 if len(trove[key]) == 0:
589 if ask_yn('Add "%s"' % desc[4:] + ' :: ' + key, 'n') == 'y':
590 classifiers.add(desc[4:] + ' :: ' + key)
591 continue
592
593 if ask_yn('Do you want to set items under\n "%s" (%d sub-items)'
594 % (key, len(trove[key])), 'n',
595 _helptext['trove_generic']) == 'y':
596 self.walk_classifiers(classifiers, trovepath + [trove[key]],
597 desc + ' :: ' + key)
598
599 def set_license(self, classifiers):
600 while True:
601 license = ask('What license do you use',
602 helptext=_helptext['trove_license'], required=False)
603 if not license:
604 return
605
606 license_words = license.lower().split(' ')
607 found_list = []
608
609 for index, licence in LICENCES:
610 for word in license_words:
611 if word in licence:
612 found_list.append(index)
613 break
614
615 if len(found_list) == 0:
616 print('ERROR: Could not find a matching license for "%s"' %
617 license)
618 continue
619
620 question = 'Matching licenses:\n\n'
621
622 for index, list_index in enumerate(found_list):
623 question += ' %s) %s\n' % (index + 1,
624 _CLASSIFIERS_LIST[list_index])
625
626 question += ('\nType the number of the license you wish to use or '
627 '? to try again:')
628 choice = ask(question, required=False)
629
630 if choice == '?':
631 continue
632 if choice == '':
633 return
634
635 try:
636 index = found_list[int(choice) - 1]
637 except ValueError:
638 print("ERROR: Invalid selection, type a number from the list "
639 "above.")
640
641 classifiers.add(_CLASSIFIERS_LIST[index])
642
643 def set_maturity_status(self, classifiers):
644 maturity_name = lambda mat: mat.split('- ')[-1]
645 maturity_question = '''\
646 Please select the project status:
647
648 %s
649
650 Status''' % '\n'.join('%s - %s' % (i, maturity_name(n))
651 for i, n in enumerate(PROJECT_MATURITY))
652 while True:
653 choice = ask(dedent(maturity_question), required=False)
654
655 if choice:
656 try:
657 choice = int(choice) - 1
658 key = PROJECT_MATURITY[choice]
659 classifiers.add(key)
660 return
661 except (IndexError, ValueError):
662 print("ERROR: Invalid selection, type a single digit "
663 "number.")
664
665
666def main():
667 """Main entry point."""
668 program = MainProgram()
669 # # uncomment when implemented
670 # if not program.load_existing_setup_script():
671 # program.inspect_directory()
672 # program.query_user()
673 # program.update_config_file()
674 # program.write_setup_script()
675 # packaging.util.cfg_to_args()
676 program()
677
678
679if __name__ == '__main__':
680 main()