• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

idlesign / makeapp / 15539601990

09 Jun 2025 04:36PM UTC coverage: 90.121% (+0.03%) from 90.087%
15539601990

push

github

idlesign
Add basic linting rules

821 of 911 relevant lines covered (90.12%)

3.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.02
/src/makeapp/appmaker.py
1
import logging
4✔
2
import os
4✔
3
import re
4✔
4
from datetime import date
4✔
5
from pathlib import Path
4✔
6
from typing import Any
4✔
7

8
import requests
4✔
9

10
from .apptemplate import AppTemplate, TemplateFile
4✔
11
from .exceptions import AppMakerException
4✔
12
from .helpers.vcs import VcsHelper
4✔
13
from .helpers.venvs import VenvHelper
4✔
14
from .rendering import Renderer
4✔
15
from .utils import PYTHON_VERSION, chdir, configure_logging, get_user_dir, read_ini
4✔
16

17
RE_UNKNOWN_MARKER = re.compile(r'{{ [^}]+ }}')
4✔
18
BASE_PATH = os.path.dirname(__file__)
4✔
19

20

21
class AppMaker:
4✔
22
    """Scaffolding functionality is encapsulated in this class.
23

24
    Usage example:
25
        app_maker = AppMaker('my_app')
26
        app_maker.rollout('/home/idle/dev/my_app_env/')
27

28
    This will create `my_app` application skeleton in `/home/idle/dev/my_app_env/`.
29

30
    """
31
    template_default_name = '__default__'
4✔
32
    package_dir_marker = '__package_name__'
4✔
33

34
    LICENSE_NO = 'no'
4✔
35
    LICENSE_MIT = 'mit'
4✔
36
    LICENSE_APACHE = 'apache2'
4✔
37
    LICENSE_GPL2 = 'gpl2'
4✔
38
    LICENSE_GPL3 = 'gpl3'
4✔
39
    LICENSE_BSD2CL = 'bsd2cl'
4✔
40
    LICENSE_BSD3CL = 'bsd3cl'
4✔
41
    # https://spdx.github.io/spdx-spec/v2.2.2/SPDX-license-list/
42
    LICENSES = {
4✔
43
        LICENSE_NO: ('No License', 'LicenseRef-Proprietary'),
44
        LICENSE_MIT: ('MIT License', 'MIT'),
45
        LICENSE_APACHE: ('Apache v2 License', 'Apache-2.0'),
46
        LICENSE_GPL2: ('GPL v2 License', 'GPL-2.0-only'),
47
        LICENSE_GPL3: ('GPL v3 License', 'GPL-3.0-only'),
48
        LICENSE_BSD2CL: ('BSD 2-Clause License', 'BSD-2-Clause'),
49
        LICENSE_BSD3CL: ('BSD 3-Clause License', 'BSD-3-Clause'),
50
    }
51
    default_license = LICENSE_BSD3CL
4✔
52

53
    VCS = VcsHelper.registry
4✔
54

55
    default_vcs = list(VCS.keys())[0]
4✔
56

57
    BASE_SETTINGS = {
4✔
58
        'app_name': None,
59
        'package_name': None,
60
        'description': 'Sample short description',
61
        'author': '{{ app_name }} contributors',
62
        'author_email': '',
63
        'url': 'https://pypi.python.org/pypi/{{ app_name }}',
64
        'year': str(date.today().year),
65
        'license': default_license,
66
        'license_title': LICENSES[default_license][0],
67
        'license_ident': LICENSES[default_license][1],
68
        'vcs': default_vcs,
69
        'vcs_remote': None,
70
        'python_version': '.'.join(map(str, PYTHON_VERSION[:2])),
71
    }
72

73
    app_template_default: AppTemplate = None
4✔
74
    """Default (root) application template object. Populated at runtime."""
4✔
75

76
    def __init__(
4✔
77
            self,
78
            app_name: str,
79
            templates_to_use: list[str] = None,
80
            templates_path: str = None,
81
            log_level: int = None
82
    ):
83
        """Initializes app maker object.
84

85
        :param app_name: Application name
86
        :param templates_to_use: Templates names or paths to use for skeleton creation
87
        :param templates_path: A path where application skeleton templates reside
88
        :param log_level: Logging
89

90
        """
91
        self.logger = logging.getLogger(self.__class__.__name__)
4✔
92
        self.configure_logging(log_level)
4✔
93

94
        self.path_user_confs = os.path.join(get_user_dir(), '.makeapp')
4✔
95
        self.path_templates_builtin = os.path.join(BASE_PATH, 'app_templates')
4✔
96
        self.path_templates_license = os.path.join(BASE_PATH, 'license_templates')
4✔
97

98
        self.user_settings_config = os.path.join(self.path_user_confs, 'makeapp.conf')
4✔
99
        self.path_templates_current = self._get_templates_path_current(templates_path)
4✔
100

101
        self.logger.debug(f'Templates path: {self.path_templates_current}')
4✔
102

103
        self.app_templates: list[AppTemplate] = []
4✔
104
        self._init_app_templates(templates_to_use)
4✔
105

106
        self.settings = self._init_settings(app_name)
4✔
107

108
        search_paths = [
4✔
109
            self.path_templates_builtin,
110
            self.path_templates_current,
111
        ]
112

113
        # Support for user-supplied template directories.
114
        for template in templates_to_use or []:
4✔
115
            if '/' in template:
4✔
116
                parent = str(Path(template).parent)
4✔
117
                if parent not in search_paths:
4✔
118
                    search_paths.append(parent)
4✔
119

120
        self.renderer = Renderer(maker=self, paths=search_paths)
4✔
121

122
        self._hook_run('rollout_init')
4✔
123

124
    def _init_settings(self, app_name: str) -> dict:
4✔
125
        """Initializes and returns base settings.
126
        
127
        :param app_name:
128

129
        """
130
        settings = dict(self.BASE_SETTINGS)
4✔
131
        self.logger.debug(f'Initial settings: {settings}')
4✔
132

133
        package_name = app_name.split('-', 1)[-1].replace('-', '_')
4✔
134

135
        self.update_settings({
4✔
136
            'app_name': app_name,
137
            'package_name': package_name,
138
            'vcs_remote': None,
139
        }, settings)
140

141
        return settings
4✔
142

143
    def _get_templates_path_current(self, path: str | None) -> str:
4✔
144
        """Returns current templates path.
145
        
146
        :param path:
147

148
        """
149
        path_user_templates = os.path.join(self.path_user_confs, 'app_templates')
4✔
150

151
        if path is None:
4✔
152
            path = path_user_templates if os.path.exists(path_user_templates) else self.path_templates_builtin
4✔
153

154
        if not os.path.exists(path):
4✔
155
            raise AppMakerException(f"Templates path doesn't exist: {path}.")
×
156

157
        return path
4✔
158

159
    def _init_app_templates(self, names_or_paths: list[str]):
4✔
160
        """Initializes app templates.
161
        
162
        :param names_or_paths:
163

164
        """
165
        if not names_or_paths:
4✔
166
            names_or_paths = []
4✔
167

168
        names_or_paths = [name for name in names_or_paths if name]
4✔
169

170
        default_template_name = self.template_default_name
4✔
171

172
        # Prepend default (base) template.
173
        if not names_or_paths or default_template_name not in names_or_paths:
4✔
174
            names_or_paths.insert(0, default_template_name)
4✔
175

176
        prev_template = None
4✔
177

178
        for template_spec in names_or_paths:
4✔
179

180
            prev_template = AppTemplate.contribute_to_maker(
4✔
181
                maker=self,
182
                template=template_spec,
183
                parent=prev_template,
184
            )
185

186
        self.logger.debug(f'Templates to use: {self.app_templates}')
4✔
187

188
    def _replace_settings_markers(self, target: Any, strip_unknown: bool = False, settings: dict = None) -> str:
4✔
189
        """Replaces settings markers in `target` with current settings values
190

191
        :param target:
192
        :param strip_unknown: Strip unknown markers from the target.
193
        :param settings:
194

195
        """
196
        settings = settings or self.settings
4✔
197

198
        if target is not None:
4✔
199

200
            for name, val in settings.items():
4✔
201

202
                if val is not None:
4✔
203
                    target = str(target).replace('{{ %s }}' % name, str(val))
4✔
204

205
        if strip_unknown:
4✔
206
            target = re.sub(RE_UNKNOWN_MARKER, '', target)
×
207

208
        return target
4✔
209

210
    def check_app_name_is_available(self):
4✔
211
        """Check some sites whether an application name is not already in use.
212

213
        :return: boolean
214

215
        """
216
        app_name = self.settings['app_name']
4✔
217

218
        self.logger.info(f'Checking `{app_name}` name is available ...')
4✔
219

220
        sites_registry = {
4✔
221
            'PyPI': f'https://pypi.org/simple/{app_name}/',
222
        }
223

224
        name_available = True
4✔
225

226
        for label, url in sites_registry.items():
4✔
227
            response = requests.get(url)
4✔
228

229
            if response.status_code == 200:
4✔
230
                self.logger.warning(f'Application name seems to be in use: {label} - {url}')
4✔
231
                name_available = False
4✔
232
                break
4✔
233

234
        if name_available:
4✔
235
            self.logger.info(
4✔
236
                f"Application name `{self.settings['app_name']}` seems "
237
                f"to be available (no mention found at: {', '.join(sites_registry)})")
238

239
        return name_available
4✔
240

241
    def configure_logging(self, verbosity_lvl: int = None, format: str = '%(message)s'):
4✔
242
        """Switches on logging at a given level.
243

244
        :param verbosity_lvl:
245
        :param format:
246

247
        """
248
        configure_logging(verbosity_lvl, logger=self.logger, format=format)
4✔
249

250
    def _get_template_files(self) -> dict:
4✔
251
        """Returns a dictionary containing all source files paths [gathered from different
252
        templates], indexed by relative paths.
253

254
        """
255
        template_files = {}
4✔
256

257
        for template in self.app_templates:
4✔
258
            template_files.update(template.get_files())
4✔
259

260
        self.logger.debug(f'Template files: {template_files}')
4✔
261

262
        return template_files
4✔
263

264
    def _hook_run(self, hook_name: str) -> dict[AppTemplate, bool]:
4✔
265
        """Runs the named hook for every app template.
266

267
        Returns results dictionary indexed by app template objects.
268

269
        :param hook_name:
270

271
        """
272
        results = {}
4✔
273

274
        for app_template in self.app_templates:
4✔
275
            results[app_template] = app_template.run_config_hook(hook_name)
4✔
276

277
        return results
4✔
278

279
    def rollout(
4✔
280
            self,
281
            dest: str,
282
            *,
283
            overwrite: bool = False,
284
            init_repository: bool = False,
285
            init_venv: bool = False,
286
            remote_address: str = None,
287
            remote_push: bool = False
288
    ):
289
        """Rolls out the application skeleton into `dest` path.
290

291
        :param dest: App skeleton destination path.
292

293
        :param overwrite: Whether to overwrite existing files.
294

295
        :param init_repository: Whether to initialize a repository.
296

297
        :param init_venv: Whether to initialize a virtual environment.
298

299
        :param remote_address: Remote repository address to add to DVCS.
300

301
        :param remote_push: Whether to push to remote.
302

303
        """
304
        self.logger.info(f'Application target path: {dest}')
4✔
305

306
        # Make remote available for hooks.
307
        self.settings['vcs_remote'] = remote_address
4✔
308

309
        try:
4✔
310
            os.makedirs(dest)
4✔
311

312
        except OSError:
4✔
313
            pass
4✔
314

315
        if os.path.exists(dest) and overwrite:
4✔
316
            self.logger.warning(
4✔
317
                f'Target path already exists: {dest}. '
318
                f'Conflict files will be overwritten.')
319

320
        license_txt, license_src = self._get_license_data()
4✔
321
        license_src = self._comment_out(license_src)
4✔
322
        license_dest = os.path.join(dest, 'LICENSE')
4✔
323

324
        if not os.path.exists(license_dest) or overwrite:
4✔
325
            self._create_file(license_dest, license_txt)
4✔
326

327
        with chdir(dest):
4✔
328
            self._hook_run('rollout_pre')
4✔
329

330
        files = self._get_template_files()
4✔
331
        for target, template_file in files.items():
4✔
332
            target = os.path.join(dest, target)
4✔
333

334
            if not os.path.exists(target) or overwrite:
4✔
335

336
                prepend = None
4✔
337

338
                if os.path.splitext(target)[1] == '.py':
4✔
339
                    # Prepend license text to source files if required.
340
                    prepend = license_src
4✔
341

342
                self._copy_file(template_file, target, prepend)
4✔
343

344
        with chdir(dest):
4✔
345
            self._hook_run('rollout_post')
4✔
346

347
            if init_venv:
4✔
348
                VenvHelper(dest).initialize()
4✔
349

350
            if init_repository:
4✔
351
                self._vcs_init(
4✔
352
                    dest,
353
                    add_files=bool(files.keys()),
354
                    remote_address=remote_address,
355
                    remote_push=remote_push)
356

357
    @staticmethod
4✔
358
    def _comment_out(text: str | None) -> str | None:
4✔
359
        """Comments out (with #) the given data.
360

361
        :param text:
362

363
        """
364
        if text is None:
4✔
365
            return None
4✔
366

367
        return '#\n#%s\n' % text.replace('\n', '\n#')
×
368

369
    def _create_file(self, path: str, contents: str):
4✔
370
        """Creates a file with the given contents in the given path.
371
        Settings markers found in contents will be replaced with
372
        the appropriate settings values.
373

374
        :param path:
375
        :param contents:
376

377
        """
378
        with open(path, 'w') as f:
4✔
379

380
            f.write(contents)
4✔
381

382
            if contents.endswith('\n'):
4✔
383
                f.write('\n')
4✔
384

385
    def _copy_file(self, src: TemplateFile, dest: str, prepend_data: str = None):
4✔
386
        """Copies a file from `src` to `dest` replacing settings markers
387
        with the given settings values, optionally prepending some data.
388

389
        :param src: source file
390
        :param dest: destination file
391
        :param prepend_data: data to prepend to dest file contents
392

393
        """
394
        self.logger.info(f'Creating {dest} ...')
4✔
395

396
        dirname = os.path.dirname(dest)
4✔
397

398
        if not os.path.exists(dirname):
4✔
399
            os.makedirs(dirname)
4✔
400

401
        data = self.renderer.render(src)
4✔
402

403
        if prepend_data is not None:
4✔
404
            data = prepend_data + data
×
405

406
        self._create_file(dest, data)
4✔
407

408
        # Copy permissions.
409
        mode = os.stat(src.path_full).st_mode
4✔
410
        os.chmod(dest, mode)
4✔
411

412
    def get_settings_string(self):
4✔
413
        """Returns settings string."""
414
        lines = [
4✔
415
            'Settings to be used: \n%s' % '\n'.join(
416
                ['    %s: %s' % (k, v) for k, v in sorted(self.settings.items(), key=lambda kv: kv[0])]
417
            ),
418
            f"Chosen license: {self.LICENSES[self.settings['license']][0]}",
419
            f"Chosen VCS: {self.VCS[self.settings['vcs']].title}",
420
        ]
421
        return '\n'.join(lines)
4✔
422

423
    def _get_license_data(self):
4✔
424
        """Returns license data: text, and boilerplate text
425
        to place into source files.
426

427
        :return: Tuple (license_text, license_src_text)
428

429
        """
430
        def render(filename):
4✔
431
            path = os.path.join(self.path_templates_license, filename)
4✔
432

433
            if os.path.exists(path):
4✔
434
                return self.renderer.render(path)
4✔
435

436
            return None
4✔
437

438
        license = self.settings['license']
4✔
439

440
        return render(license), render(f'{license}_src')
4✔
441

442
    def get_template_vars(self) -> set[str]:
4✔
443
        """Returns known template variables."""
444

445
        items = set(AppMaker.BASE_SETTINGS.keys())
4✔
446

447
        for app_template in self.app_templates:
4✔
448
            items.update(app_template.config.settings.keys())
4✔
449

450
        return items
4✔
451

452
    def update_settings_complex(self, config: str = None, dictionary: dict = None):
4✔
453
        """Updates current settings using multiple sources,
454

455
        :param config:
456
        :param dictionary:
457

458
        """
459
        # Try to read settings from default file.
460
        self.update_settings_from_file()
4✔
461

462
        # Try to read settings from user supplied configuration file.
463
        self.update_settings_from_file(config)
4✔
464

465
        # Settings from command line override all the previous.
466
        self.update_settings_from_dict(dictionary)
4✔
467

468
        # Add template specific files.
469
        self.update_settings_from_app_templates()
4✔
470

471
    def update_settings_from_app_templates(self):
4✔
472
        """Updates current settings using app templates."""
473
        self._hook_run('configure')
4✔
474

475
    def update_settings_from_dict(self, dict_: dict):
4✔
476
        """Updates settings dict with contents from a given dict.
477
        
478
        :param dict_:
479

480
        """
481
        if not dict_:
4✔
482
            return
×
483

484
        settings = {}
4✔
485

486
        for key in self.get_template_vars():
4✔
487
            val = dict_.get(key)
4✔
488
            if val is not None:
4✔
489
                settings[key] = val
4✔
490

491
        self.update_settings(settings)
4✔
492

493
    def update_settings_from_file(self, path: str = None):
4✔
494
        """Updates settings dict with contents of configuration file.
495

496
        Config example:
497

498
            [settings]
499
            author = Igor `idle sign` Starikov
500
            author_email = idlesign@yandex.ru
501
            license = mit
502
            url = https://github.com/idlesign/{{ app_name }}
503

504
        :param path: Config path. If empty default ~.makeapp config is used
505

506
        """
507
        config_path = path
4✔
508
        if path is None:
4✔
509
            config_path = self.user_settings_config
4✔
510

511
        config_exists = os.path.exists(config_path)
4✔
512
        if path is None and not config_exists:
4✔
513
            # There could be no default config file.
514
            return
4✔
515

516
        if not config_exists and path is not None:
×
517
            raise AppMakerException(f'Unable to find settings file: {config_path}.')
×
518

519
        cfg = read_ini(Path(config_path))
×
520

521
        if not cfg.has_section('settings'):
×
522
            raise AppMakerException(f'Unable to read settings from file: {config_path}.')
×
523

524
        self.update_settings(dict(cfg.items('settings')))
×
525

526
    def _vcs_init(self, dest: str, *, add_files: bool = False, remote_address: str = None, remote_push: bool = False):
4✔
527
        """Initializes an appropriate VCS repository in the given path.
528
        Optionally adds the given files.
529

530
        :param dest: Path to initialize VCS repository.
531

532
        :param add_files: Whether to add files to commit automatically.
533

534
        :param remote_address: Remote repository address to add to DVCS.
535

536
        :param remote_push: Whether to push to remote.
537

538
        """
539
        vcs = self.settings['vcs']
4✔
540

541
        helper: VcsHelper = self.VCS[vcs]()
4✔
542

543
        self.logger.info(f'Initializing {helper.title} repository ...')
4✔
544

545
        with chdir(dest):
4✔
546
            helper.init()
4✔
547
            add_files and helper.add()
4✔
548

549
            # Linking to a remote.
550
            if remote_address:
4✔
551
                helper.add_remote(remote_address)
4✔
552
                if remote_push:
4✔
553
                    helper.commit('The beginning')
×
554
                    helper.push(upstream=True)
×
555

556
    def _validate_setting(self, setting: str, variants: list[str], settings: dict):
4✔
557
        """Ensures that the given setting value is one from the given variants."""
558
        val = settings[setting]
4✔
559

560
        if val not in variants:
4✔
561

562
            raise AppMakerException(
×
563
                f'Unsupported value `{val}` for `{setting}`. '
564
                f'Acceptable variants [{variants}].')
565

566
    def update_settings(self, settings_new: dict, settings_base: dict = None):
4✔
567
        """Updates current settings dictionary with values from a given
568
        settings dictionary. Settings markers existing in settings dict will
569
        be replaced with previously calculated settings values.
570

571
        :param settings_new:
572
        :param settings_base:
573
        
574
        """
575
        settings_base = settings_base or self.settings
4✔
576

577
        settings_base.update(settings_new)
4✔
578
        for name, val in settings_base.items():
4✔
579
            settings_base[name] = self._replace_settings_markers(val, settings=settings_base)
4✔
580

581
        self._validate_setting('license', list(self.LICENSES), settings_base)
4✔
582

583
        license = self.LICENSES[settings_base['license']]
4✔
584
        settings_base['license_title'] = license[0]
4✔
585
        settings_base['license_ident'] = license[1]
4✔
586

587
        self._validate_setting('vcs', list(self.VCS), settings_base)
4✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc