From 297b713ad4a6c989dc9ebbc3dbea9969bf68e588 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Sat, 7 Jun 2025 07:34:18 +0200 Subject: [PATCH 1/2] Init with get method Python TSToy resource --- samples/python/resources/first/.gitignore | 166 +++++++++++++ samples/python/resources/first/README.md | 3 + samples/python/resources/first/build.ps1 | 95 +++++++ .../resources/first/src/commands/get.py | 51 ++++ .../resources/first/src/config/config.py | 233 ++++++++++++++++++ .../resources/first/src/config/manager.py | 171 +++++++++++++ .../resources/first/src/core/console.py | 12 + samples/python/resources/first/src/main.py | 15 ++ .../resources/first/src/models/models.py | 25 ++ .../python/resources/first/src/pyproject.toml | 21 ++ .../resources/first/src/resources/strings.py | 7 + .../resources/first/src/schema/schema.py | 48 ++++ .../resources/first/src/utils/logger.py | 109 ++++++++ .../first/tests/acceptance.tests.ps1 | 81 ++++++ 14 files changed, 1037 insertions(+) create mode 100644 samples/python/resources/first/.gitignore create mode 100644 samples/python/resources/first/README.md create mode 100644 samples/python/resources/first/build.ps1 create mode 100644 samples/python/resources/first/src/commands/get.py create mode 100644 samples/python/resources/first/src/config/config.py create mode 100644 samples/python/resources/first/src/config/manager.py create mode 100644 samples/python/resources/first/src/core/console.py create mode 100644 samples/python/resources/first/src/main.py create mode 100644 samples/python/resources/first/src/models/models.py create mode 100644 samples/python/resources/first/src/pyproject.toml create mode 100644 samples/python/resources/first/src/resources/strings.py create mode 100644 samples/python/resources/first/src/schema/schema.py create mode 100644 samples/python/resources/first/src/utils/logger.py create mode 100644 samples/python/resources/first/tests/acceptance.tests.ps1 diff --git a/samples/python/resources/first/.gitignore b/samples/python/resources/first/.gitignore new file mode 100644 index 0000000..863ea61 --- /dev/null +++ b/samples/python/resources/first/.gitignore @@ -0,0 +1,166 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be added to the global gitignore or merged into this project gitignore. For PyCharm +# Community Edition, use 'PyCharm CE' in the configurations. +.idea/ + +# Build output +python.zip + +# uv +.uv/ +uv.lock \ No newline at end of file diff --git a/samples/python/resources/first/README.md b/samples/python/resources/first/README.md new file mode 100644 index 0000000..3b4bcce --- /dev/null +++ b/samples/python/resources/first/README.md @@ -0,0 +1,3 @@ +# Python TSToy resource + +To be filled in diff --git a/samples/python/resources/first/build.ps1 b/samples/python/resources/first/build.ps1 new file mode 100644 index 0000000..44d662f --- /dev/null +++ b/samples/python/resources/first/build.ps1 @@ -0,0 +1,95 @@ +[CmdletBinding()] +param ( + [ValidateSet('build', 'test')] + [string]$mode = 'build', + [string]$name = 'pythontstoy' +) + +function Build-PythonProject { + [CmdletBinding()] + param ( + [Parameter()] + [string]$Name + ) + begin { + Write-Verbose -Message "Starting Python project build process" + + $sourceDir = Join-Path -Path $PSScriptRoot -ChildPath 'src' + $outputDir = Join-Path -Path $PSScriptRoot -ChildPath 'dist' + } + + process { + Install-Uv + + Push-Location -Path $sourceDir -ErrorAction Stop + + try { + # Create virtual environment + & uv venv + + # Activate it + & .\.venv\Scripts\activate.ps1 + + # Sync all the dependencies + & uv sync + + # Create executable + $pyInstallerArgs = @( + 'main.py', + '-F', + '--clean', + '--distpath', $outputDir, + '--name', $Name + ) + & pyinstaller.exe @pyInstallerArgs + } + finally { + deactivate + Pop-Location -ErrorAction Ignore + } + } + + end { + Write-Verbose -Message "Python project build process completed" + } +} + +function Install-Uv() { + begin { + Write-Verbose -Message "Installing Uv dependencies" + } + + process { + if ($IsWindows) { + if (-not (Get-Command uv -ErrorAction SilentlyContinue)) { + Write-Verbose -Message "Installing uv package manager on Windows" + Invoke-RestMethod https://astral.sh/uv/install.ps1 | Invoke-Expression + + } + $env:Path = "$env:USERPROFILE\.local\bin;$env:Path" + } + elseif ($IsLinux) { + curl -LsSf https://astral.sh/uv/install.sh | sh + } + } + + end { + Write-Verbose -Message "Uv installation process completed" + } +} + +switch ($mode) { + 'build' { + Build-PythonProject -Name $name + } + 'test' { + Build-PythonProject -Name $name + + $testContainer = New-PesterContainer -Path (Join-Path 'tests' 'acceptance.tests.ps1') -Data @{ + Name = $name + } + + Invoke-Pester -Container $testContainer -Output Detailed + } + +} \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/get.py b/samples/python/resources/first/src/commands/get.py new file mode 100644 index 0000000..f66ec61 --- /dev/null +++ b/samples/python/resources/first/src/commands/get.py @@ -0,0 +1,51 @@ +import click +import json +import sys +from utils.logger import Logger +from config.config import Settings + +# Create a logger instance for this module +logger = Logger() + +@click.command('get') +@click.option('--input', 'input_json', + help='JSON input data with required scope field', + required=True, + type=str) +def get_command(input_json): + """Get command that retrieves configuration based on scope from validated JSON input""" + + try: + try: + data = json.loads(input_json) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format - {e}", "get_command", input_data=input_json) + sys.exit(1) + + if not Settings.validate_input(data, logger): + sys.exit(1) + + settings = Settings.from_dict(data) + + if not settings.validate(): + logger.error(f"Settings validation failed for scope: {settings.scope}", "get_command", scope=settings.scope) + sys.exit(1) + + logger.info(f"Processing settings for scope: {settings.scope}", "get_command", scope=settings.scope) + + result_settings, err = settings.enforce() + if err: + logger.error(f"Failed to enforce settings for scope {settings.scope}", "get_command", + scope=settings.scope, error_details=str(err)) + sys.exit(1) + + result_settings.print_config() + logger.info(f"Settings retrieved successfully for scope: {settings.scope}", "get_command", + scope=settings.scope) + + return result_settings + + except Exception as e: + logger.critical(f"Unexpected error in get_command", "get_command", + error_type=type(e).__name__, error_message=str(e)) + sys.exit(1) \ No newline at end of file diff --git a/samples/python/resources/first/src/config/config.py b/samples/python/resources/first/src/config/config.py new file mode 100644 index 0000000..e015991 --- /dev/null +++ b/samples/python/resources/first/src/config/config.py @@ -0,0 +1,233 @@ +from dataclasses import dataclass, field +from typing import Literal, Optional, Dict, Any, Tuple, Set +from pathlib import Path +from config.manager import ConfigManager +from utils.logger import Logger +from schema.schema import validate_resource, RESOURCE_SCHEMA +import json + +@dataclass +class Settings: + scope: Literal["user", "machine"] + _exist: bool = True + updateAutomatically: Optional[bool] = None + updateFrequency: Optional[int] = None + config_path: str = field(default="", init=False, repr=False) + _provided_properties: Set[str] = field(default_factory=set, init=False, repr=False) + + def __post_init__(self): + """Initialize dependencies after dataclass creation""" + self.config_manager = ConfigManager() + self.logger = Logger() + + @classmethod + def validate_input(cls, data: Dict[str, Any], logger: Logger) -> bool: + is_valid, validation_error = validate_resource(data) + if not is_valid: + logger.error(f"Input validation failed: {validation_error}", "Settings") + return False + + allowed_properties = list(RESOURCE_SCHEMA["properties"].keys()) + logger.info(f"Valid properties per schema: {allowed_properties}", "Settings") + logger.info(f"Provided properties: {list(data.keys())}", "Settings") + + return True + + def get_config_path(self) -> Tuple[str, Optional[Exception]]: + try: + if not self.scope: + return "", ValueError("scope is required") + + if self.scope == "machine": + config_path = self.config_manager.get_machine_config_path() + elif self.scope == "user": + config_path = self.config_manager.get_user_config_path() + else: + return "", ValueError(f"invalid scope: {self.scope}") + + self.config_path = str(config_path) + return self.config_path, None + + except Exception as e: + return "", e + + def get_config_map(self) -> Tuple[Optional[Dict[str, Any]], Optional[Exception]]: + try: + config_path, err = self.get_config_path() + if err: + return None, err + + if not Path(config_path).exists(): + self.logger.info(f"Config file not found: {config_path}", "Settings") + return {}, None # Return empty map if file doesn't exist + + # Load configuration + config_data = self.config_manager.load_config_file(Path(config_path)) + if config_data is None: + self.logger.info(f"Config file loaded but empty: {config_path}", "Settings") + return {}, None + + self.logger.info(f"Config loaded successfully: {json.dumps(config_data)}", "Settings") + return config_data, None + + except Exception as e: + return None, e + + def get_config_settings(self) -> Tuple[Optional['Settings'], Optional[Exception]]: + try: + config_map, err = self.get_config_map() + if err: + return None, err + + if not config_map: + self.logger.info("No configuration found, returning with _exist=False", "Settings") + return Settings(scope=self.scope, _exist=False), None + + settings = Settings(scope=self.scope, _exist=True) + + if 'updates' in config_map: + updates = config_map['updates'] + + if 'updateAutomatically' in updates: + settings.updateAutomatically = bool(updates['updateAutomatically']) + + if 'updateFrequency' in updates: + settings.updateFrequency = int(updates['updateFrequency']) + + self.logger.info(f"Loaded settings - updateAutomatically: {settings.updateAutomatically}, " + + f"updateFrequency: {settings.updateFrequency}", "Settings") + else: + self.logger.info("No 'updates' section found in config file", "Settings") + + return settings, None + + except Exception as e: + self.logger.error(f"Error in get_config_settings: {str(e)}", "Settings") + return None, e + + def enforce(self) -> Tuple[Optional['Settings'], Optional[Exception]]: + try: + current_settings, err = self.get_config_settings() + if err: + return None, err + + if not current_settings: + self.logger.info("Failed to get settings, returning with scope only", "Settings") + return Settings(scope=self.scope, _exist=True), None + + if not Path(self.config_path).exists(): + self.logger.info("Config file doesn't exist, returning scope only with _exist=false", "Settings") + result = Settings(scope=self.scope, _exist=False) + result.updateAutomatically = None + result.updateFrequency = None + return result, None + + self.logger.info("Current settings from config file: " + + f"updateAutomatically={current_settings.updateAutomatically}, " + + f"updateFrequency={current_settings.updateFrequency}", "Settings") + + if self._has_properties_to_validate(): + validated_settings = self._validate_settings(current_settings) + return validated_settings, None + else: + self.logger.info("No properties to validate, returning all: " + + f"updateAutomatically={current_settings.updateAutomatically}, " + + f"updateFrequency={current_settings.updateFrequency}", "Settings") + + return current_settings, None + + except Exception as e: + self.logger.error(f"Error in enforce: {str(e)}", "Settings") + return None, e + + def _has_properties_to_validate(self) -> bool: + properties = set(self._provided_properties) + if 'scope' in properties: + properties.remove('scope') + if '_exist' in properties: + properties.remove('_exist') + + has_properties = len(properties) > 0 + self.logger.info(f"Properties to validate: {properties}, has_properties: {has_properties}", "Settings") + return has_properties + + def _validate_settings(self, current_settings: 'Settings') -> 'Settings': + validated_settings = Settings( + scope=self.scope, + _exist=True, # Start with True, will be set to False if validation fails + updateAutomatically=current_settings.updateAutomatically, + updateFrequency=current_settings.updateFrequency + ) + + validation_failed = False + + if 'updateAutomatically' in self._provided_properties: + if current_settings.updateAutomatically is None: + validation_failed = True + self.logger.info(f"updateAutomatically not found in config (requested: {self.updateAutomatically})", "Settings") + elif current_settings.updateAutomatically != self.updateAutomatically: + validation_failed = True + self.logger.info(f"updateAutomatically mismatch - requested: {self.updateAutomatically}, found: {current_settings.updateAutomatically}", "Settings") + else: + self.logger.info(f"updateAutomatically validation passed: {self.updateAutomatically}", "Settings") + + if 'updateFrequency' in self._provided_properties: + if current_settings.updateFrequency is None: + validation_failed = True + self.logger.info(f"updateFrequency not found in config (requested: {self.updateFrequency})", "Settings") + elif current_settings.updateFrequency != self.updateFrequency: + validation_failed = True + self.logger.info(f"updateFrequency mismatch - requested: {self.updateFrequency}, found: {current_settings.updateFrequency}", "Settings") + else: + self.logger.info(f"updateFrequency validation passed: {self.updateFrequency}", "Settings") + + if validation_failed: + validated_settings._exist = False + self.logger.info("Validation failed, setting _exist to False", "Settings") + else: + self.logger.info("All validations passed, _exist remains True", "Settings") + + return validated_settings + + def to_json(self, exclude_private: bool = False, exclude_none: bool = True) -> str: + data = {} + data["_exist"] = self._exist + + if self.scope is not None: + data["scope"] = self.scope + + if not exclude_none or self.updateAutomatically is not None: + data["updateAutomatically"] = self.updateAutomatically + + if not exclude_none or self.updateFrequency is not None: + data["updateFrequency"] = self.updateFrequency + + if exclude_none: + data = {k: v for k, v in data.items() if v is not None or k == "_exist"} + + self.logger.info(f"Serializing to JSON: {data}", "Settings") + + return json.dumps(data) + + def print_config(self) -> None: + json_output = self.to_json(exclude_private=False, exclude_none=True) + self.logger.info(f"Printing configuration: {json_output}", "Settings") + print(json_output) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Settings': + settings = cls( + scope=data.get('scope'), + _exist=data.get('_exist', True), # Default to True if not specified + updateAutomatically=data.get('updateAutomatically'), + updateFrequency=data.get('updateFrequency') + ) + + settings._provided_properties = set(data.keys()) + + return settings + + def validate(self) -> bool: + if not self.scope or self.scope not in ['user', 'machine']: + return False + return True \ No newline at end of file diff --git a/samples/python/resources/first/src/config/manager.py b/samples/python/resources/first/src/config/manager.py new file mode 100644 index 0000000..25f5d45 --- /dev/null +++ b/samples/python/resources/first/src/config/manager.py @@ -0,0 +1,171 @@ +import os +import json +import pathlib +from typing import Dict, Any +from resources.strings import Strings +from utils.logger import Logger + + +class ConfigSource: + """Represents a configuration source.""" + DEFAULT = "default" + MACHINE = "machine" + USER = "user" + ENV = "environment" + CLI = "cli" + +class ConfigManager: + def __init__(self): + self.default_config = { + "updates": { + "updateAutomatically": False, + "updateFrequency": 180 + } + } + self.machine_config = {} + self.user_config = {} + self.env_config = {} + self.cli_config = {} + self.config = {} + + # Track loaded sources for reporting + self.loaded_sources = [] + + self.logger = Logger() + + def get_machine_config_path(self) -> pathlib.Path: + if os.name == 'nt': # Windows + return pathlib.Path(os.environ.get('PROGRAMDATA', 'C:/ProgramData')) / 'tstoy' / 'config.json' + else: # Unix-like + return pathlib.Path('/etc/tstoy/config.json') + + def get_user_config_path(self) -> pathlib.Path: + if os.name == 'nt': # Windows + config_dir = pathlib.Path(os.environ.get('APPDATA')) + else: # Unix-like + config_dir = pathlib.Path.home() / '.config' + + return config_dir / 'tstoy' / 'config.json' + + def load_config_file(self, path: pathlib.Path) -> Dict[str, Any]: + if not path.exists(): + self.logger.info(Strings.CONFIG_NOT_FOUND.format(path)) + return {} + + try: + with open(path, 'r') as f: + config = json.load(f) + self.logger.info(Strings.CONFIG_LOADED.format(path)) + return config + except json.JSONDecodeError as e: + self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) + return {} + except IOError as e: + self.logger.error(Strings.CONFIG_INVALID.format(path, str(e))) + return {} + + def save_config_file(self, path: pathlib.Path, config: Dict[str, Any]) -> bool: + try: + # Create parent directories if they don't exist + path.parent.mkdir(parents=True, exist_ok=True) + + with open(path, 'w') as f: + json.dump(config, f, indent=2) + self.logger.info(Strings.CONFIG_UPDATED.format(path)) + return True + except Exception as e: + self.logger.error(Strings.ERROR_WRITE_CONFIG.format(path, str(e))) + return False + + def load_default_config(self): + self.config = self.default_config.copy() + self.loaded_sources.append(ConfigSource.DEFAULT) + + def load_machine_config(self): + path = self.get_machine_config_path() + self.machine_config = self.load_config_file(path) + if self.machine_config: + self._merge_config(self.machine_config) + self.loaded_sources.append(ConfigSource.MACHINE) + + def load_user_config(self): + path = self.get_user_config_path() + self.user_config = self.load_config_file(path) + if self.user_config: + self._merge_config(self.user_config) + self.loaded_sources.append(ConfigSource.USER) + + def load_environment_config(self, prefix: str): + env_config = {} + for key, value in os.environ.items(): + if key.startswith(prefix): + # Convert DSCPY_UPDATES_AUTOMATIC to updates.automatic + config_key = key[len(prefix):].lower().replace('_', '.') + + # Convert string value to appropriate type + if value.lower() in ('true', 'yes', '1'): + env_config[config_key] = True + elif value.lower() in ('false', 'no', '0'): + env_config[config_key] = False + elif value.isdigit(): + env_config[config_key] = int(value) + else: + env_config[config_key] = value + + if env_config: + self.env_config = env_config + self._merge_config(env_config) + self.loaded_sources.append(ConfigSource.ENV) + + + def _merge_config(self, source: Dict[str, Any]): + def deep_merge(target, source): + for key, value in source.items(): + if key in target and isinstance(target[key], dict) and isinstance(value, dict): + deep_merge(target[key], value) + else: + target[key] = value + + deep_merge(self.config, source) + + def get_merged_config(self) -> Dict[str, Any]: + return self.config + + def get_config_sources(self) -> list: + return self.loaded_sources + + def get_all_config_files(self) -> list: + try: + user_config_dir = self.get_user_config_path().parent + if user_config_dir.exists(): + # Return a list of all JSON files in the config directory + return list(user_config_dir.glob('*.json')) + else: + self.logger.warning(f"Config directory does not exist: {user_config_dir}") + return [] + except Exception as e: + self.logger.error(f"Error enumerating config files: {str(e)}") + return [] + + def get_config_by_name(self, name: str) -> Dict[str, Any]: + if name == 'default': + return self.default_config.copy() + + # Try to find a specific config file with this name + user_config_dir = self.get_user_config_path().parent + config_path = user_config_dir / f"{name}.json" + + if config_path.exists(): + config = self.load_config_file(config_path) + return config + else: + # If no specific file exists, return the merged config + # This behavior can be changed based on requirements + self.logger.warning(f"No configuration found for name: {name}") + return self.get_merged_config() + + def load_all_configs(self, env_prefix: str = "TSTOY_"): + self.load_default_config() + self.load_machine_config() + self.load_user_config() + self.load_environment_config(env_prefix) diff --git a/samples/python/resources/first/src/core/console.py b/samples/python/resources/first/src/core/console.py new file mode 100644 index 0000000..d5e95ac --- /dev/null +++ b/samples/python/resources/first/src/core/console.py @@ -0,0 +1,12 @@ +class Console: + @staticmethod + def info(message: str): + print(f"INFO: {message}") + + @staticmethod + def error(message: str): + print(f"ERROR: {message}") + + @staticmethod + def warning(message: str): + print(f"WARNING: {message}") diff --git a/samples/python/resources/first/src/main.py b/samples/python/resources/first/src/main.py new file mode 100644 index 0000000..28c2ce3 --- /dev/null +++ b/samples/python/resources/first/src/main.py @@ -0,0 +1,15 @@ +import click +from commands.get import get_command + + +@click.group() +def main(): + """Python TSToy CLI tool.""" + pass + +# TODO: Add more commands and move away in main.py +main.add_command(get_command) + + +if __name__ == '__main__': + main() diff --git a/samples/python/resources/first/src/models/models.py b/samples/python/resources/first/src/models/models.py new file mode 100644 index 0000000..501e57a --- /dev/null +++ b/samples/python/resources/first/src/models/models.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass, asdict +from typing import Literal, Optional + +import json + +@dataclass +class TsToy: + scope: Literal["user", "machine"] + _exist: bool = True + updateAutomatically: Optional[bool] = None + updateFrequency: Optional[int] = None + + def to_json(self, include_none: bool = False) -> str: + data = asdict(self) + if not include_none: + data = {k: v for k, v in data.items() if v is not None} + return json.dumps(data) + + def to_dict(self, include_none: bool = False) -> dict: + data = asdict(self) + + if not include_none: + data = {k: v for k, v in data.items() if v is not None} + + return data \ No newline at end of file diff --git a/samples/python/resources/first/src/pyproject.toml b/samples/python/resources/first/src/pyproject.toml new file mode 100644 index 0000000..e3a0914 --- /dev/null +++ b/samples/python/resources/first/src/pyproject.toml @@ -0,0 +1,21 @@ +[build-system] +requires = ["setuptools>=45", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "tstoy" +version = "0.1.0" +description = "A command-line interface application built with Click" +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "click>=8.2.1", + "jsonschema>=4.24.0", + "pyinstaller>=6.13.0", +] + +[project.scripts] +tstoy = "main:main" + +[tool.setuptools] +packages = ["commands", "core", "resources", "utils"] diff --git a/samples/python/resources/first/src/resources/strings.py b/samples/python/resources/first/src/resources/strings.py new file mode 100644 index 0000000..fe7bc6e --- /dev/null +++ b/samples/python/resources/first/src/resources/strings.py @@ -0,0 +1,7 @@ +# TODO: Convert to localization strings +class Strings: + CONFIG_NOT_FOUND = "Configuration file not found: {}" + CONFIG_LOADED = "Configuration loaded from: {}" + CONFIG_INVALID = "Invalid configuration file {}: {}" + CONFIG_UPDATED = "Configuration saved to: {}" + ERROR_WRITE_CONFIG = "Error writing configuration to {}: {}" diff --git a/samples/python/resources/first/src/schema/schema.py b/samples/python/resources/first/src/schema/schema.py new file mode 100644 index 0000000..b0208ce --- /dev/null +++ b/samples/python/resources/first/src/schema/schema.py @@ -0,0 +1,48 @@ +import jsonschema +import json + +RESOURCE_SCHEMA = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Python TSToy Resource", + "type": "object", + "required": ["scope"], + "additionalProperties": False, + "properties": { + "scope": { + "title": "Target configuration scope", + "description": "Defines which of TSToy's config files to manage.", + "type": "string", + "enum": ["machine", "user"], + }, + "_exist": { + "title": "Should configuration exist", + "description": "Defines whether the config file should exist.", + "type": "boolean", + "default": True, + }, + "updateAutomatically": { + "title": "Should update automatically", + "description": "Indicates whether TSToy should check for updates when it starts.", + "type": "boolean", + }, + "updateFrequency": { + "title": "Update check frequency", + "description": "Indicates how many days TSToy should wait before checking for updates.", + "type": "integer", + "minimum": 1, + "maximum": 180, + }, + } +} + +def validate_resource(instance): + """Validate resource instance against schema.""" + try: + jsonschema.validate(instance=instance, schema=RESOURCE_SCHEMA) + return True, None + except jsonschema.exceptions.ValidationError as err: + return False, f"Validation error: {err.message}" + +def get_schema(): + """Dump the schema as formatted JSON string.""" + return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) \ No newline at end of file diff --git a/samples/python/resources/first/src/utils/logger.py b/samples/python/resources/first/src/utils/logger.py new file mode 100644 index 0000000..c158c96 --- /dev/null +++ b/samples/python/resources/first/src/utils/logger.py @@ -0,0 +1,109 @@ +import json +import sys +import datetime +import inspect +from typing import Dict, Any +from enum import Enum + + +class LogLevel(Enum): + """Enumeration for log levels""" + DEBUG = "DEBUG" + INFO = "INFO" + WARNING = "WARNING" + ERROR = "ERROR" + CRITICAL = "CRITICAL" + + +class Logger: + """ + A structured JSON logger class that outputs messages to stderr. + + Features: + - JSON formatted output + - Configurable log levels + - Automatic timestamp generation + - Caller information tracking + - Customizable output stream + """ + + def __init__(self, output_stream=None, include_caller_info: bool = True): + self.output_stream = output_stream or sys.stderr + self.include_caller_info = include_caller_info + + def _get_caller_info(self) -> Dict[str, Any]: + if not self.include_caller_info: + return {} + + try: + # Get the frame of the caller (skip internal methods) + frame = inspect.currentframe() + for _ in range(3): # Skip _get_caller_info, _log, and the log level method + frame = frame.f_back + if frame is None: + break + + if frame: + return { + "file": frame.f_code.co_filename.split('\\')[-1], # Just filename + "line": frame.f_lineno, + "function": frame.f_code.co_name + } + except Exception: + pass + + return {} + + def _log(self, level: LogLevel, message: str, target: str = None, **kwargs): + log_entry = { + "timestamp": datetime.datetime.now().isoformat() + "Z", + "level": level.value, + "fields": {"message": message}, + "target": target or "unknown" + } + + # Add caller information if enabled + caller_info = self._get_caller_info() + if caller_info: + log_entry["line_number"] = caller_info.get("line", "Unknown") + log_entry["file"] = caller_info.get("file", "Unknown") + log_entry["function"] = caller_info.get("function", "Unknown") + + # Add any additional fields to the fields section + if kwargs: + log_entry["fields"].update(kwargs) + + try: + json_output = json.dumps(log_entry, separators=(",", ":")) + self.output_stream.write(json_output + '\n') + self.output_stream.flush() + except Exception as e: + # Fallback to basic error output + fallback_msg = f"[LOG ERROR] Failed to write log: {str(e)}\n" + self.output_stream.write(fallback_msg) + self.output_stream.flush() + + def debug(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.DEBUG, message, target, **kwargs) + + def info(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.INFO, message, target, **kwargs) + + def warning(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.WARNING, message, target, **kwargs) + + def error(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.ERROR, message, target, **kwargs) + + def critical(self, message: str, target: str = None, **kwargs): + self._log(LogLevel.CRITICAL, message, target, **kwargs) + + def log_config_loaded(self, config_path: str, config_type: str, **kwargs): + self.info(f"Loaded {config_type} configuration", "config_manager", + config_path=config_path, **kwargs) + + def log_config_error(self, error_msg: str, config_path: str = None, **kwargs): + self.error(f"Configuration error: {error_msg}", "config_manager", + config_path=config_path, **kwargs) + + diff --git a/samples/python/resources/first/tests/acceptance.tests.ps1 b/samples/python/resources/first/tests/acceptance.tests.ps1 new file mode 100644 index 0000000..54a468d --- /dev/null +++ b/samples/python/resources/first/tests/acceptance.tests.ps1 @@ -0,0 +1,81 @@ +param ( + [string]$Name = 'pythontstoy' +) + +BeforeAll { + $oldPath = $env:Path + $env:Path = [System.IO.Path]::PathSeparator + (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist') + + if ($IsWindows) { + $script:machinePath = Join-Path $env:ProgramData 'tstoy' 'config.json' + $script:userPath = Join-Path $env:APPDATA 'tstoy' 'config.json' + } + else { + $script:machinePath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' + $script:userPath = Join-Path $env:HOME '.config' 'tstoy' 'config.json' + } +} + +Describe 'TSToy acceptance tests' { + Context "Help command" { + It 'Should return help' { + $help = & $Name --help + $help | Should -Not -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + } + } + + Context "Input validation" { + It 'Should fail with invalid input' { + $out = & $Name get --input '{}' 2>&1 + $LASTEXITCODE | Should -Be 1 + $out | Should -BeLike '*"level":"ERROR"*"message":"Input validation failed: Validation error: ''scope'' is a required property"*' + } + } + + Context "Scope validation" -ForEach @( @{ scope = 'user' }, @{ scope = 'machine' } ) { + BeforeAll { + if ($IsWindows) { + Remove-Item -Path $script:userPath -ErrorAction Ignore + Remove-Item -Path $script:machinePath -ErrorAction Ignore + } + elseif ($IsLinux) { + Remove-Item -Path $script:userPath -ErrorAction Ignore + Remove-Item -Path $script:machinePath -ErrorAction Ignore + } + } + + It "Should not exist scope: " { + $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out._exist | Should -BeFalse + } + + It 'Should exist when file is present' { + $config = @{ + updates = @{ + updateAutomatically = $false + updateFrequency = 180 + } + } | ConvertTo-Json -Depth 10 + + if ($_.scope -eq 'user') { + $scriptPath = $script:userPath + } + else { + $scriptPath = $script:machinePath + } + + New-Item -Path $scriptPath -ItemType File -Value $config -Force | Out-Null + + $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 + $out._exist | Should -BeTrue + } + } +} + + +AfterAll { + $env:Path = $oldPath +} \ No newline at end of file From 34ae98cc915420accf11957c445acca0154e0978 Mon Sep 17 00:00:00 2001 From: "G.Reijn" Date: Tue, 10 Jun 2025 17:52:26 +0200 Subject: [PATCH 2/2] New methods --- .../resources/first/src/commands/common.py | 44 ++ .../resources/first/src/commands/export.py | 34 ++ .../resources/first/src/commands/get.py | 50 +-- .../resources/first/src/commands/root.py | 55 +++ .../resources/first/src/commands/set.py | 32 ++ .../resources/first/src/config/config.py | 411 +++++++++++++----- samples/python/resources/first/src/main.py | 17 +- .../resources/first/src/schema/schema.py | 8 +- .../first/tests/acceptance.tests.ps1 | 82 +++- 9 files changed, 568 insertions(+), 165 deletions(-) create mode 100644 samples/python/resources/first/src/commands/common.py create mode 100644 samples/python/resources/first/src/commands/export.py create mode 100644 samples/python/resources/first/src/commands/root.py create mode 100644 samples/python/resources/first/src/commands/set.py diff --git a/samples/python/resources/first/src/commands/common.py b/samples/python/resources/first/src/commands/common.py new file mode 100644 index 0000000..587fed8 --- /dev/null +++ b/samples/python/resources/first/src/commands/common.py @@ -0,0 +1,44 @@ +import click + +def common(function): + """Add common options to click commands""" + function = click.option( + "--input", + "input_json", + help="JSON input data with settings", + required=False, + type=str, + )(function) + + function = click.option( + "--scope", + help="Target configuration scope (user or machine)", + type=click.Choice(["user", "machine"]), + required=False, + )(function) + + function = click.option( + "--exist", + "exist", + help="Check if configuration exists", + is_flag=True, + default=None, + )(function) + + function = click.option( + "--updateAutomatically", + "updateAutomatically", + help="Whether updates should be automatic", + type=bool, + required=False, + )(function) + + function = click.option( + "--updateFrequency", + "updateFrequency", + help="Update frequency in days (1-180)", + type=int, + required=False, + )(function) + + return function \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/export.py b/samples/python/resources/first/src/commands/export.py new file mode 100644 index 0000000..77de8d9 --- /dev/null +++ b/samples/python/resources/first/src/commands/export.py @@ -0,0 +1,34 @@ +import click +import json +import sys +from utils.logger import Logger +from config.config import Settings + +logger = Logger() + +@click.command("export") +def export_command(): + """Export all configuration settings (user and machine) as JSON. + + This command retrieves both user and machine configurations and + outputs them as a JSON object. If a configuration doesn't + exist, it will show the default values. + """ + try: + user_settings = Settings(scope="user") + machine_settings = Settings(scope="machine") + + user_result, user_err = Settings.get_current_state(user_settings, logger) + machine_result, machine_err = Settings.get_current_state(machine_settings, logger) + + if user_err: + logger.warning(f"Error retrieving user configuration: {user_err}", "export_command") + if machine_err: + logger.warning(f"Error retrieving machine configuration: {machine_err}", "export_command") + + print(user_result.to_json()) + print(machine_result.to_json()) + + except Exception as e: + logger.critical(f"Unexpected error in export command: {str(e)}", "export_command") + sys.exit(1) \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/get.py b/samples/python/resources/first/src/commands/get.py index f66ec61..17da7c2 100644 --- a/samples/python/resources/first/src/commands/get.py +++ b/samples/python/resources/first/src/commands/get.py @@ -1,51 +1,29 @@ import click -import json import sys from utils.logger import Logger from config.config import Settings +from commands.common import common -# Create a logger instance for this module logger = Logger() -@click.command('get') -@click.option('--input', 'input_json', - help='JSON input data with required scope field', - required=True, - type=str) -def get_command(input_json): - """Get command that retrieves configuration based on scope from validated JSON input""" - +@click.command("get") +@common +def get_command(input_json, scope, exist, updateAutomatically, updateFrequency): + """Gets the current state of a tstoy configuration file.""" try: - try: - data = json.loads(input_json) - except json.JSONDecodeError as e: - logger.error(f"Invalid JSON format - {e}", "get_command", input_data=input_json) - sys.exit(1) - - if not Settings.validate_input(data, logger): - sys.exit(1) - + data = Settings.validate( + input_json, scope, exist, updateAutomatically, updateFrequency, 'get', logger + ) + settings = Settings.from_dict(data) - - if not settings.validate(): - logger.error(f"Settings validation failed for scope: {settings.scope}", "get_command", scope=settings.scope) - sys.exit(1) - - logger.info(f"Processing settings for scope: {settings.scope}", "get_command", scope=settings.scope) - - result_settings, err = settings.enforce() + + result_settings, err = Settings.get_current_state(settings, logger) if err: - logger.error(f"Failed to enforce settings for scope {settings.scope}", "get_command", - scope=settings.scope, error_details=str(err)) + logger.error(f"Failed to get settings: {err}", "get_command") sys.exit(1) - + result_settings.print_config() - logger.info(f"Settings retrieved successfully for scope: {settings.scope}", "get_command", - scope=settings.scope) - - return result_settings except Exception as e: - logger.critical(f"Unexpected error in get_command", "get_command", - error_type=type(e).__name__, error_message=str(e)) + logger.critical(f"Unexpected error: {str(e)}", "get_command") sys.exit(1) \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/root.py b/samples/python/resources/first/src/commands/root.py new file mode 100644 index 0000000..41e81a4 --- /dev/null +++ b/samples/python/resources/first/src/commands/root.py @@ -0,0 +1,55 @@ +import click +from utils.logger import Logger +from schema.schema import get_schema +from commands.get import get_command +from commands.set import set_command +from commands.export import export_command + +logger = Logger() + +# Custom group class for better help formatting +class CustomGroup(click.Group): + def format_help(self, ctx, formatter): + super().format_help(ctx, formatter) + + formatter.write(""" +Flags: + --input TEXT JSON input data with settings + --scope [user|machine] Target configuration scope (user or machine) + --exist Whether the configuration should exist + --updateAutomatically Enable automatic updates + --updateFrequency INTEGER Update frequency in days (1-180) +""") + formatter.write("\nExamples:\n") + formatter.write(" pythontstoy get --scope user\n") + formatter.write(" pythontstoy set --scope user --updateAutomatically\n") + formatter.write(" pythontstoy get --input '{\"scope\": \"user\"}'\n") + formatter.write(" '{\"scope\": \"user\"}' | pythontstoy set\n") + formatter.write(" pythontstoy schema\n") + +@click.command("schema") +def schema_command(): + """Get the schema of the tstoy configuration.""" + print(get_schema()) + +@click.command('help') +@click.pass_context +def help_command(ctx): + """Show help information for commands.""" + parent = ctx.parent + click.echo(parent.get_help()) + +@click.group(cls=CustomGroup) +def cli(): + """Command-line tool for managing configurations. + + Use 'get' to retrieve configuration or 'set' to modify configuration. + Use 'schema' to view the configuration schema. + """ + pass + +cli.add_command(get_command) +cli.add_command(set_command) +cli.add_command(schema_command) +cli.add_command(help_command) +cli.add_command(export_command) \ No newline at end of file diff --git a/samples/python/resources/first/src/commands/set.py b/samples/python/resources/first/src/commands/set.py new file mode 100644 index 0000000..8560d8a --- /dev/null +++ b/samples/python/resources/first/src/commands/set.py @@ -0,0 +1,32 @@ +import click +import sys +from utils.logger import Logger +from config.config import Settings +from commands.common import common +logger = Logger() + +@click.command("set") +@common +def set_command(input_json, scope, exist, updateAutomatically, updateFrequency): + """Sets a tstoy configuration file to the desired state.""" + + data = Settings.validate( + input_json, scope, exist, updateAutomatically, updateFrequency, 'set', logger + ) + + try: + settings = Settings.from_dict(data) + + result, err = Settings.enforce(settings, logger) + if err: + logger.error(f"Failed to set configuration: {err}", "set_command") + sys.exit(1) + + except Exception as e: + logger.critical( + "Unexpected error in set_command", + "set_command", + error_type=type(e).__name__, + error_message=str(e), + ) + sys.exit(1) diff --git a/samples/python/resources/first/src/config/config.py b/samples/python/resources/first/src/config/config.py index e015991..684533e 100644 --- a/samples/python/resources/first/src/config/config.py +++ b/samples/python/resources/first/src/config/config.py @@ -1,10 +1,14 @@ +import json +import click +import sys + from dataclasses import dataclass, field from typing import Literal, Optional, Dict, Any, Tuple, Set from pathlib import Path from config.manager import ConfigManager from utils.logger import Logger from schema.schema import validate_resource, RESOURCE_SCHEMA -import json + @dataclass class Settings: @@ -16,9 +20,294 @@ class Settings: _provided_properties: Set[str] = field(default_factory=set, init=False, repr=False) def __post_init__(self): - """Initialize dependencies after dataclass creation""" self.config_manager = ConfigManager() self.logger = Logger() + + @staticmethod + def validate(input_json, scope, exist, updateAutomatically, updateFrequency, command_name="command", logger=None): + if logger is None: + logger = Logger() + + data = {} + data = Settings._read_stdin(command_name) + + if data: + try: + data = json.loads(data) + logger.info(f"Parsed JSON from stdin: {json.dumps(data)}", command_name) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format from stdin: {e}", command_name) + click.echo(f"Error: Invalid JSON format from stdin: {e}", err=True) + sys.exit(1) + if input_json and not data: # Only if we don't already have data from stdin + try: + data = json.loads(input_json) + logger.info(f"Parsed JSON from --input: {json.dumps(data)}", command_name) + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON format from --input: {e}", command_name) + click.echo(f"Error: Invalid JSON format from --input: {e}", err=True) + sys.exit(1) + + if not data: + data = {} + + final_scope = scope if scope is not None else data.get('scope') + + if not final_scope: + logger.error("Input validation failed: Validation error: 'scope' is a required property", "Settings.validate") + sys.exit(1) + + if final_scope not in ['user', 'machine']: + logger.error(f"Input validation failed: Validation error: 'scope' must be either 'user' or 'machine', got '{final_scope}'", "Settings.validate") + sys.exit(1) + + if scope is not None: + data['scope'] = scope + logger.info(f"Added scope from parameter: {scope}", command_name) + + if exist is not None: + data['_exist'] = exist + logger.info(f"Added _exist from parameter: {exist}", command_name) + + if updateAutomatically is not None: + data['updateAutomatically'] = updateAutomatically + logger.info(f"Added updateAutomatically from parameter: {updateAutomatically}", command_name) + + if updateFrequency is not None: + data['updateFrequency'] = updateFrequency + logger.info(f"Added updateFrequency from parameter: {updateFrequency}", command_name) + if not data: + click.echo("Error: No input provided. You must specify either --input or at least --scope.", err=True) + click.echo("\nExamples:", err=True) + click.echo(f" python main.py {command_name} --scope user", err=True) + click.echo(f" python main.py {command_name} --scope user --updateAutomatically true", err=True) + click.echo(f" python main.py {command_name} --input '{{\"scope\": \"user\"}}'", err=True) + click.echo(f" echo '{{\"scope\": \"user\"}}' | python main.py {command_name}", err=True) + click.echo(f"\nRun 'python main.py {command_name} --help' for full usage information.", err=True) + sys.exit(1) + + logger.info(f"Validating input data against schema: {json.dumps(data)}", command_name) + is_valid, validation_error = validate_resource(data) + if not is_valid: + logger.error(f"Schema validation failed: {validation_error}", command_name) + click.echo(f"Error: {validation_error}", err=True) + sys.exit(1) + + logger.info(f"Final validated input data: {json.dumps(data)}", command_name) + return data + + @staticmethod + def get_current_state(settings_request: 'Settings', logger=None) -> Tuple[Optional['Settings'], Optional[Exception]]: + if logger is None: + logger = Logger() + + try: + config_manager = ConfigManager() + + if settings_request.scope == "machine": + config_path = config_manager.get_machine_config_path() + elif settings_request.scope == "user": + config_path = config_manager.get_user_config_path() + else: + return None, ValueError(f"invalid scope: {settings_request.scope}") + + config_path_str = str(config_path) + + if not Path(config_path_str).exists(): + logger.info(f"Config file doesn't exist: {config_path_str}", "Settings") + result = Settings(scope=settings_request.scope, _exist=False) + result.updateAutomatically = None + result.updateFrequency = None + return result, None + + config_data = config_manager.load_config_file(Path(config_path_str)) + if config_data is None or not config_data: + logger.info(f"Config file loaded but empty: {config_path_str}", "Settings") + return Settings(scope=settings_request.scope, _exist=True), None + + logger.info(f"Config loaded successfully: {json.dumps(config_data)}", "Settings") + + current_settings = Settings(scope=settings_request.scope, _exist=True) + + if 'updates' in config_data: + updates = config_data['updates'] + if 'updateAutomatically' in updates: + current_settings.updateAutomatically = bool(updates['updateAutomatically']) + + if 'updateFrequency' in updates: + current_settings.updateFrequency = int(updates['updateFrequency']) + + logger.info(f"Loaded settings: updateAutomatically={current_settings.updateAutomatically}, " + f"updateFrequency={current_settings.updateFrequency}", "Settings") + else: + logger.info("No 'updates' section found in config file", "Settings") + + if Settings._has_properties_to_validate(settings_request): + validated_settings = Settings._validate_settings( + settings_request, current_settings, logger + ) + return validated_settings, None + else: + logger.info("No properties to validate, returning all", "Settings") + return current_settings, None + except Exception as e: + logger.error(f"Error in enforce: {str(e)}", "Settings") + return None, e + + @staticmethod + def enforce(settings: 'Settings', logger=None) -> Tuple[Optional['Settings'], Optional[Exception]]: + if logger is None: + logger = Logger() + + try: + config_path, err = settings.get_config_path() + if err: + return None, err + + settings.config_path = config_path + + config_path_obj = Path(config_path) + + if settings._exist is False: + logger.info(f"Deleting configuration file: {config_path}", "enforce") + + if config_path_obj.exists(): + try: + config_path_obj.unlink() + logger.info(f"Successfully deleted configuration file: {config_path}", "enforce") + return settings, None + except Exception as e: + return None, Exception(f"Failed to delete configuration file: {str(e)}") + else: + logger.info(f"Configuration file already doesn't exist: {config_path}", "enforce") + return settings, None + + settings = Settings._set_default_values(settings) + + config_map = Settings._create_config_map(settings) + + config_path_obj.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(config_path_obj, 'w') as f: + json.dump(config_map, f, indent=2) + logger.info(f"Successfully wrote configuration to: {config_path}", "enforce") + return settings, None + except Exception as e: + return None, Exception(f"Failed to write configuration file: {str(e)}") + + except Exception as e: + return None, e + + @staticmethod + def _read_stdin(command_name="command", logger=None): + if logger is None: + logger = Logger() + + if not sys.stdin.isatty(): + logger.info("Reading input from stdin", command_name) + try: + # Read the entire content from stdin + stdin_data = sys.stdin.read().strip() + if stdin_data: + logger.info(f"Read from stdin: {stdin_data}", command_name) + return stdin_data + else: + logger.info("Stdin was empty", command_name) + return None + except Exception as e: + logger.error(f"Error reading from stdin: {str(e)}", command_name) + return None + return None + + @staticmethod + def _set_default_values(settings: 'Settings', logger=None) -> 'Settings': + + if logger is None: + logger = Logger() + + if settings.updateAutomatically is None: + default = RESOURCE_SCHEMA["properties"]["updateAutomatically"].get("default") + if default is not None: + settings.updateAutomatically = default + + if settings.updateFrequency is None: + default = RESOURCE_SCHEMA["properties"]["updateFrequency"].get("default") + if default is not None: + settings.updateFrequency = default + return settings + + @staticmethod + def _create_config_map(settings: 'Settings') -> dict: + config_map = {} + + updates = {} + + if settings.updateAutomatically is not None: + updates["updateAutomatically"] = settings.updateAutomatically + + if settings.updateFrequency is not None: + updates["updateFrequency"] = settings.updateFrequency + + if updates: + config_map["updates"] = updates + + return config_map + + @staticmethod + def _has_properties_to_validate(settings: 'Settings') -> bool: + properties = set(settings._provided_properties) + if 'scope' in properties: + properties.remove('scope') + if '_exist' in properties: + properties.remove('_exist') + + has_properties = len(properties) > 0 + return has_properties + + @staticmethod + def _validate_settings(request_settings: 'Settings', current_settings: 'Settings', + logger: Logger) -> 'Settings': + validated_settings = Settings( + scope=request_settings.scope, + _exist=True, # Start with True, will be set to False if validation fails + updateAutomatically=current_settings.updateAutomatically, + updateFrequency=current_settings.updateFrequency + ) + + validation_failed = False + + if 'updateAutomatically' in request_settings._provided_properties: + if current_settings.updateAutomatically is None: + validation_failed = True + logger.info(f"updateAutomatically not found in config (requested: {request_settings.updateAutomatically})", + "Settings") + elif current_settings.updateAutomatically != request_settings.updateAutomatically: + validation_failed = True + logger.info(f"updateAutomatically mismatch - requested: {request_settings.updateAutomatically}, " + + f"found: {current_settings.updateAutomatically}", "Settings") + else: + logger.info(f"updateAutomatically validation passed: {request_settings.updateAutomatically}", "Settings") + + if 'updateFrequency' in request_settings._provided_properties: + if current_settings.updateFrequency is None: + validation_failed = True + logger.info(f"updateFrequency not found in config (requested: {request_settings.updateFrequency})", + "Settings") + elif current_settings.updateFrequency != request_settings.updateFrequency: + validation_failed = True + logger.info(f"updateFrequency mismatch - requested: {request_settings.updateFrequency}, " + + f"found: {current_settings.updateFrequency}", "Settings") + else: + logger.info(f"updateFrequency validation passed: {request_settings.updateFrequency}", "Settings") + + if validation_failed: + validated_settings._exist = False + logger.info("Validation failed, setting _exist to False", "Settings") + else: + logger.info("All validations passed, _exist remains True", "Settings") + + return validated_settings @classmethod def validate_input(cls, data: Dict[str, Any], logger: Logger) -> bool: @@ -26,13 +315,24 @@ def validate_input(cls, data: Dict[str, Any], logger: Logger) -> bool: if not is_valid: logger.error(f"Input validation failed: {validation_error}", "Settings") return False - allowed_properties = list(RESOURCE_SCHEMA["properties"].keys()) logger.info(f"Valid properties per schema: {allowed_properties}", "Settings") logger.info(f"Provided properties: {list(data.keys())}", "Settings") return True + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> 'Settings': + settings = cls( + scope=data.get('scope'), + _exist=data.get('_exist', True), # Default to True if not specified + updateAutomatically=data.get('updateAutomatically'), + updateFrequency=data.get('updateFrequency') + ) + + settings._provided_properties = set(data.keys()) + return settings + def get_config_path(self) -> Tuple[str, Optional[Exception]]: try: if not self.scope: @@ -105,90 +405,6 @@ def get_config_settings(self) -> Tuple[Optional['Settings'], Optional[Exception] self.logger.error(f"Error in get_config_settings: {str(e)}", "Settings") return None, e - def enforce(self) -> Tuple[Optional['Settings'], Optional[Exception]]: - try: - current_settings, err = self.get_config_settings() - if err: - return None, err - - if not current_settings: - self.logger.info("Failed to get settings, returning with scope only", "Settings") - return Settings(scope=self.scope, _exist=True), None - - if not Path(self.config_path).exists(): - self.logger.info("Config file doesn't exist, returning scope only with _exist=false", "Settings") - result = Settings(scope=self.scope, _exist=False) - result.updateAutomatically = None - result.updateFrequency = None - return result, None - - self.logger.info("Current settings from config file: " + - f"updateAutomatically={current_settings.updateAutomatically}, " + - f"updateFrequency={current_settings.updateFrequency}", "Settings") - - if self._has_properties_to_validate(): - validated_settings = self._validate_settings(current_settings) - return validated_settings, None - else: - self.logger.info("No properties to validate, returning all: " + - f"updateAutomatically={current_settings.updateAutomatically}, " + - f"updateFrequency={current_settings.updateFrequency}", "Settings") - - return current_settings, None - - except Exception as e: - self.logger.error(f"Error in enforce: {str(e)}", "Settings") - return None, e - - def _has_properties_to_validate(self) -> bool: - properties = set(self._provided_properties) - if 'scope' in properties: - properties.remove('scope') - if '_exist' in properties: - properties.remove('_exist') - - has_properties = len(properties) > 0 - self.logger.info(f"Properties to validate: {properties}, has_properties: {has_properties}", "Settings") - return has_properties - - def _validate_settings(self, current_settings: 'Settings') -> 'Settings': - validated_settings = Settings( - scope=self.scope, - _exist=True, # Start with True, will be set to False if validation fails - updateAutomatically=current_settings.updateAutomatically, - updateFrequency=current_settings.updateFrequency - ) - - validation_failed = False - - if 'updateAutomatically' in self._provided_properties: - if current_settings.updateAutomatically is None: - validation_failed = True - self.logger.info(f"updateAutomatically not found in config (requested: {self.updateAutomatically})", "Settings") - elif current_settings.updateAutomatically != self.updateAutomatically: - validation_failed = True - self.logger.info(f"updateAutomatically mismatch - requested: {self.updateAutomatically}, found: {current_settings.updateAutomatically}", "Settings") - else: - self.logger.info(f"updateAutomatically validation passed: {self.updateAutomatically}", "Settings") - - if 'updateFrequency' in self._provided_properties: - if current_settings.updateFrequency is None: - validation_failed = True - self.logger.info(f"updateFrequency not found in config (requested: {self.updateFrequency})", "Settings") - elif current_settings.updateFrequency != self.updateFrequency: - validation_failed = True - self.logger.info(f"updateFrequency mismatch - requested: {self.updateFrequency}, found: {current_settings.updateFrequency}", "Settings") - else: - self.logger.info(f"updateFrequency validation passed: {self.updateFrequency}", "Settings") - - if validation_failed: - validated_settings._exist = False - self.logger.info("Validation failed, setting _exist to False", "Settings") - else: - self.logger.info("All validations passed, _exist remains True", "Settings") - - return validated_settings - def to_json(self, exclude_private: bool = False, exclude_none: bool = True) -> str: data = {} data["_exist"] = self._exist @@ -205,29 +421,10 @@ def to_json(self, exclude_private: bool = False, exclude_none: bool = True) -> s if exclude_none: data = {k: v for k, v in data.items() if v is not None or k == "_exist"} - self.logger.info(f"Serializing to JSON: {data}", "Settings") - return json.dumps(data) def print_config(self) -> None: json_output = self.to_json(exclude_private=False, exclude_none=True) self.logger.info(f"Printing configuration: {json_output}", "Settings") print(json_output) - - @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Settings': - settings = cls( - scope=data.get('scope'), - _exist=data.get('_exist', True), # Default to True if not specified - updateAutomatically=data.get('updateAutomatically'), - updateFrequency=data.get('updateFrequency') - ) - - settings._provided_properties = set(data.keys()) - - return settings - - def validate(self) -> bool: - if not self.scope or self.scope not in ['user', 'machine']: - return False - return True \ No newline at end of file + \ No newline at end of file diff --git a/samples/python/resources/first/src/main.py b/samples/python/resources/first/src/main.py index 28c2ce3..d996a01 100644 --- a/samples/python/resources/first/src/main.py +++ b/samples/python/resources/first/src/main.py @@ -1,15 +1,4 @@ -import click -from commands.get import get_command +from commands.root import cli - -@click.group() -def main(): - """Python TSToy CLI tool.""" - pass - -# TODO: Add more commands and move away in main.py -main.add_command(get_command) - - -if __name__ == '__main__': - main() +if __name__ == "__main__": + cli() \ No newline at end of file diff --git a/samples/python/resources/first/src/schema/schema.py b/samples/python/resources/first/src/schema/schema.py index b0208ce..8232fa5 100644 --- a/samples/python/resources/first/src/schema/schema.py +++ b/samples/python/resources/first/src/schema/schema.py @@ -1,5 +1,7 @@ import jsonschema import json +import click +import sys RESOURCE_SCHEMA = { "$schema": "https://json-schema.org/draft/2020-12/schema", @@ -24,6 +26,7 @@ "title": "Should update automatically", "description": "Indicates whether TSToy should check for updates when it starts.", "type": "boolean", + "default": True, }, "updateFrequency": { "title": "Update check frequency", @@ -31,12 +34,12 @@ "type": "integer", "minimum": 1, "maximum": 180, + "default": 90, }, } } def validate_resource(instance): - """Validate resource instance against schema.""" try: jsonschema.validate(instance=instance, schema=RESOURCE_SCHEMA) return True, None @@ -44,5 +47,4 @@ def validate_resource(instance): return False, f"Validation error: {err.message}" def get_schema(): - """Dump the schema as formatted JSON string.""" - return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) \ No newline at end of file + return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) diff --git a/samples/python/resources/first/tests/acceptance.tests.ps1 b/samples/python/resources/first/tests/acceptance.tests.ps1 index 54a468d..009748d 100644 --- a/samples/python/resources/first/tests/acceptance.tests.ps1 +++ b/samples/python/resources/first/tests/acceptance.tests.ps1 @@ -4,7 +4,7 @@ param ( BeforeAll { $oldPath = $env:Path - $env:Path = [System.IO.Path]::PathSeparator + (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist') + $env:Path += [System.IO.Path]::PathSeparator + (Join-Path (Split-Path $PSScriptRoot -Parent) 'dist') if ($IsWindows) { $script:machinePath = Join-Path $env:ProgramData 'tstoy' 'config.json' @@ -16,7 +16,20 @@ BeforeAll { } } -Describe 'TSToy acceptance tests' { +Describe "TSToy acceptance tests - Schema command" { + It "Should return schema" { + $schema = & $Name schema | ConvertFrom-Json + $schema | Should -Not -BeNullOrEmpty + $LASTEXITCODE | Should -Be 0 + $schema.required | Should -Contain 'scope' + $schema.properties.scope | Should -Not -BeNullOrEmpty + $schema.properties.updateFrequency | Should -Not -BeNullOrEmpty + $schema.properties.updateAutomatically | Should -Not -BeNullOrEmpty + $schema.properties._exist | Should -Not -BeNullOrEmpty + } +} + +Describe 'TSToy acceptance tests - Get command' { Context "Help command" { It 'Should return help' { $help = & $Name --help @@ -29,7 +42,7 @@ Describe 'TSToy acceptance tests' { It 'Should fail with invalid input' { $out = & $Name get --input '{}' 2>&1 $LASTEXITCODE | Should -Be 1 - $out | Should -BeLike '*"level":"ERROR"*"message":"Input validation failed: Validation error: ''scope'' is a required property"*' + $out[1] | Should -BeLike '*"ERROR"*"message":"Input validation failed: Validation error: ''scope'' is a required property"*' } } @@ -71,11 +84,70 @@ Describe 'TSToy acceptance tests' { $out = & $Name get --input ($_ | ConvertTo-Json -Depth 10) | ConvertFrom-Json $LASTEXITCODE | Should -Be 0 $out._exist | Should -BeTrue + $out.updateFrequency | Should -Be 180 + $out.updateAutomatically | Should -BeFalse } } } +Describe "TSToy acceptance tests - Set command" { + It "Should set user scope" { + $config = @{ + scope = 'user' + updateAutomatically = $false + updateFrequency = 180 + } | ConvertTo-Json -Depth 10 -AfterAll { - $env:Path = $oldPath + & $Name set --input $config + $LASTEXITCODE | Should -Be 0 + + $out = & $Name get --input $config | ConvertFrom-Json + $out | Should -Not -BeNullOrEmpty + $out._exist | Should -BeTrue + $out.updateFrequency | Should -Be 180 + $out.updateAutomatically | Should -BeFalse + } + + It "Should set machine scope" { + $config = @{ + scope = 'machine' + updateAutomatically = $true + updateFrequency = 10 + } | ConvertTo-Json -Depth 10 + + & $Name set --input $config + $LASTEXITCODE | Should -Be 0 + + $out = & $Name get --input $config | ConvertFrom-Json + $out | Should -Not -BeNullOrEmpty + $out._exist | Should -BeTrue + $out.updateFrequency | Should -Be 10 + $out.updateAutomatically | Should -BeTrue + } + + It "Should delete user scope" { + $config = @{ + scope = 'user' + _exist = $false + } | ConvertTo-Json -Depth 10 + + & $Name set --input $config + $LASTEXITCODE | Should -Be 0 + + $out = & $Name get --input $config | ConvertFrom-Json + $out._exist | Should -BeFalse + } + + It "Should delete machine scope" { + $config = @{ + scope = 'machine' + _exist = $false + } | ConvertTo-Json -Depth 10 + + & $Name set --input $config + $LASTEXITCODE | Should -Be 0 + + $out = & $Name get --input $config | ConvertFrom-Json + $out._exist | Should -BeFalse + } } \ No newline at end of file