#!/usr/bin/env python3
# -*- coding: UTF-8 -*-
"""
Create and validate MicroPython package.json files based on Python setup.py
files
"""
import json
import logging
import sys
from distutils.core import run_setup
from pathlib import Path
from typing import List, Optional, Tuple
from changelog2version.extract_version import ExtractVersion
from deepdiff import DeepDiff
from mock import Mock
[docs]class Setup2uPyPackageError(Exception):
"""Base class for exceptions in this module."""
pass
[docs]class Setup2uPyPackage(object):
"""Handle MicroPython package JSON creation and validation"""
def __init__(self,
setup_file: Path,
package_file: Optional[Path],
package_changelog_file: Optional[Path],
logger: Optional[logging.Logger] = None) -> None:
"""
Init Setup2uPyPackage class
:param setup_file: The setup.py file
:type setup_file: Path
:param package_file: The package.json file
:type package_file: Optional[Path]
:param package_file: The package changelog file
:type package_file: Optional[Path]
:param logger: Logger object
:type logger: Optional[logging.Logger]
"""
if logger is None:
logger = self._create_logger()
self._logger = logger
self._setup_file = setup_file
self._package_file = package_file
self._package_changelog_file = package_changelog_file
self._setup_data = {}
self._root_dir = self._setup_file.parent
self._setup_data = self._parse_setup_file_content()
[docs] @staticmethod
def _create_logger(logger_name: str = None) -> logging.Logger:
"""
Create a logger
:param logger_name: The logger name
:type logger_name: str, optional
:returns: Configured logger
:rtype: logging.Logger
"""
custom_format = '[%(asctime)s] [%(levelname)-8s] [%(filename)-15s @'\
' %(funcName)-15s:%(lineno)4s] %(message)s'
# configure logging
logging.basicConfig(level=logging.INFO,
format=custom_format,
stream=sys.stdout)
if logger_name and (isinstance(logger_name, str)):
logger = logging.getLogger(logger_name)
else:
logger = logging.getLogger(__name__)
# set the logger level to DEBUG if specified differently
logger.setLevel(logging.DEBUG)
return logger
[docs] def _parse_setup_file_content(self) -> dict:
"""
Parse setup.py file content
see https://stackoverflow.com/a/61754034/13543363
:returns: Parsed setup.py file content
:rtype: dict
"""
sys.modules['sdist_upip'] = Mock()
res = run_setup(self._setup_file, stop_after="init")
kwargs = res.__dict__
kwargs.update(kwargs['metadata'].__dict__)
return kwargs
@property
def package_version(self) -> str:
"""
Get version of package based on setup.py "version" entry
:returns: Package version based on setup.py "version" entry
:rtype: str
"""
if self._setup_data.get('version', ""):
return self._setup_data['version']
else:
self._logger.warning("No 'version' key found in setup data dict")
return "-1.-1.-1"
@property
def package_changelog_version(self) -> str:
"""
Get package changelog version
:returns: Package changelog version
:rtype: str
"""
if self._package_changelog_file:
ev = ExtractVersion(logger=self._logger)
version_line = ev.parse_changelog(
changelog_file=self._package_changelog_file
)
semver_string = ev.parse_semver_line(
release_version_line=version_line
)
return semver_string
else:
self._logger.warning("No package changelog file specified")
return "-1.-1.-1"
@property
def package_deps(self) -> List[str]:
"""
Get dependencies of package based on setup.py "install_requires" entry
:returns: Package dependencies based on setup.py "install_requires"
:rtype: List[str]
"""
if self._setup_data.get('install_requires', []):
return self._setup_data['install_requires']
else:
self._logger.warning(
"No 'install_requires' key found in setup data dict"
)
return []
@property
def package_url(self) -> str:
"""
Get URL of package based on setup.py "url" entry.
:returns: Package URL based on setup.py "url" entry
:rtype: str
"""
if self._setup_data.get('url', ""):
return self._setup_data['url']
else:
self._logger.warning("No 'url' key found in setup data dict")
raise SystemExit('Project URL is mandatory')
@property
def package_files(self) -> List[str]:
"""
Get packages based on setup.py "packages" entry.
:returns: Packages based on setup.py "packages" entry
:rtype: List[str]
"""
packages = []
all_files = []
root_dir = self._root_dir
if self._setup_data.get('packages', []):
packages = self._setup_data['packages']
else:
self._logger.warning("No 'packages' key found in setup data dict")
return []
for package in packages:
p = root_dir.glob('{}/*.py'.format(package))
files = [x.relative_to(root_dir) for x in p if x.is_file()]
all_files.extend(files)
return all_files
@property
def data_files(self) -> List[str]:
"""
Get data files based on setup.py "data_files" entry.
:returns: Data files based on setup.py "data_files" entry
:rtype: List[str]
"""
data_files = []
all_files = []
root_dir = self._root_dir
if self._setup_data.get('data_files', []):
data_files = self._setup_data['data_files']
else:
self._logger.warning(
"No 'data_files' key found in setup data dict"
)
return []
for folder, file_list in data_files:
files = []
for file in file_list:
file = root_dir / Path(file)
if file.is_file():
files.append(file.relative_to(root_dir))
all_files.extend(files)
return all_files
[docs] def _create_url_elements(self,
package_files: List[str],
url: str) -> List[str]:
"""
Create URLs to all package elements.
:param package_files: The package files
:type package_files: List[str]
:param url: The URL
:type url: str
:returns: List of URLs to download the package files
:rtype: List[str]
"""
urls = []
for file in package_files:
this_url = [
str(file),
str(Path(url) / file)
]
self._logger.debug("File elements: {}: {}".format(file, this_url))
urls.append(this_url)
return urls
@property
def package_data(self) -> dict:
"""
Get mip compatible package data
:returns: mip compatible package.json data
:rtype: dict
"""
urls = []
package_data = {
"urls": [],
"deps": [],
"version": "-1.-1.-1"
}
if self._package_changelog_file:
version = self.package_changelog_version
else:
version = self.package_version
install_requires = self.package_deps
package_files = self.package_files
data_files = self.data_files
url = self.package_url.replace('https://github.com/', 'github:')
for x in [package_files, data_files]:
urls.extend(self._create_url_elements(package_files=x, url=url))
self._logger.debug("version: {}".format(version))
self._logger.debug("install_requires: {}".format(install_requires))
self._logger.debug("package_files: {}".format(package_files))
self._logger.debug("data_files: {}".format(data_files))
self._logger.debug("url: {}".format(url))
self._logger.debug("urls: {}".format(urls))
package_data["urls"] = urls
package_data["deps"] = install_requires
package_data["version"] = version
return package_data
@property
def package_json_data(self) -> dict:
"""
Get package.json data
:returns: Existing package.json data
:rtype: dict
"""
existing_data = {}
if self._package_file:
with open(self._package_file, 'r') as f:
existing_data = json.load(f)
else:
raise Setup2uPyPackageError("No package.json data specified")
return existing_data
[docs] def validate(self,
ignore_version: bool = False,
ignore_deps: bool = False,
ignore_boot_main: bool = False) -> bool:
"""
Validate existing package.json with setup.py based data
:param ignore_version: Flag to ignore the version
:type ignore_version: bool
:param ignore_deps: Flag to ignore the dependencies
:type ignore_deps: bool
:param ignore_boot_main: Flag to ignore the main and boot files
:type ignore_boot_main: bool
:returns: Result of validation, True on success, False otherwise
:rtype: bool
"""
# list of URL entries might be sorted differently
package_json_data = dict(self.package_json_data)
package_data = dict(self.package_data)
if ignore_version:
package_json_data.pop("version", None)
package_data.pop("version", None)
if ignore_deps:
package_json_data.pop("deps", None)
package_data.pop("deps", None)
if ignore_boot_main:
package_json_data["urls"] = self._exclude_package_files(
package_files=package_json_data.get("urls")
)
package_data["urls"] = self._exclude_package_files(
package_files=package_data.get("urls")
)
package_json_data.get("urls", []).sort()
package_data.get("urls", []).sort()
return package_json_data == package_data
[docs] def _exclude_package_files(
self,
package_files: List[Tuple[str, str]],
excludes: List[str] = [
"boot.py", "main.py"
]) -> List[Tuple[str, str]]:
"""
Exclude elements of a list if the first element matches an exclude str
:param package_files: The package files
:type package_files: List[Tuple[str, str]]
:param excludes: The list of excludes
:type excludes: List[str]
:returns: List without elements matching the exclude list
:rtype: List[Tuple[str, str]]
"""
return [
ele for ele in package_files
if not any(i in ele[0] for i in excludes)
]
@property
def validation_diff(self) -> DeepDiff:
"""
Get difference of package.json and setup.py
:returns: The deep difference.
:rtype: DeepDiff
"""
return DeepDiff(self.package_data, self.package_json_data)
[docs] def create(self,
output_path: Optional[Path] = None,
pretty: bool = True) -> None:
"""
Create package.json file in same directory as setup.py
:param output_path: The output path
:type output_path: Optional[Path]
:param pretty: Flag to use an indentation of 4
:type pretty: bool
"""
if not output_path:
if self._package_file:
output_path = self._package_file
else:
output_path = self._setup_file.parent / 'package.json'
self._logger.info(
"No package.json data specified, using setup.py directory"
)
with open(output_path, 'w') as file:
if pretty:
file.write(json.dumps(self.package_data, indent=4))
else:
file.write(json.dumps(self.package_data))
self._logger.debug("Created {}".format(output_path))