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

idlesign / makeapp / 15025712491

14 May 2025 04:11PM UTC coverage: 89.934% (+0.1%) from 89.814%
15025712491

Pull #7

github

web-flow
Merge 947ece28a into 7aaf4b482
Pull Request #7: Update code and tooling.

148 of 161 new or added lines in 11 files covered. (91.93%)

822 of 914 relevant lines covered (89.93%)

3.6 hits per line

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

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

9
import requests
4✔
10

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

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

21

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

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

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

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

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

54
    VCS = VcsHelper.registry
4✔
55

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

142
        return settings
4✔
143

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

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

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

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

158
        return path
4✔
159

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

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

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

171
        default_template_name = self.template_default_name
4✔
172

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

177
        prev_template = None
4✔
178

179
        for template_spec in names_or_paths:
4✔
180

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

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

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

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

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

199
        if target is not None:
4✔
200

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

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

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

209
        return target
4✔
210

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

214
        :return: boolean
215

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

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

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

225
        name_available = True
4✔
226

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

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

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

240
        return name_available
4✔
241

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

245
        :param verbosity_lvl:
246
        :param format:
247

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

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

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

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

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

263
        return template_files
4✔
264

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

268
        Returns results dictionary indexed by app template objects.
269

270
        :param hook_name:
271

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

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

278
        return results
4✔
279

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

292
        :param dest: App skeleton destination path.
293

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

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

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

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

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

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

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

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

313
        except OSError:
4✔
314
            pass
4✔
315

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

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

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

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

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

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

337
                prepend = None
4✔
338

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

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

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

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

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

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

362
        :param text:
363

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

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

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

375
        :param path:
376
        :param contents:
377

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

381
            f.write(contents)
4✔
382

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

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

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

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

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

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

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

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

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

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

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

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

428
        :return: Tuple (license_text, license_src_text)
429

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

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

437
            return None
4✔
438

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

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

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

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

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

451
        return items
4✔
452

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

456
        :param config:
457
        :param dictionary:
458

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

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

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

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

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

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

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

485
        settings = {}
4✔
486

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

492
        self.update_settings(settings)
4✔
493

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

497
        Config example:
498

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

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

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

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

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

NEW
520
        cfg = read_ini(Path(config_path))
×
521

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

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

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

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

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

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

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

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

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

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

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

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

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

561
        if val not in variants:
4✔
562

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

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

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

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

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

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

588
        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