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/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 new file mode 100644 index 0000000..17da7c2 --- /dev/null +++ b/samples/python/resources/first/src/commands/get.py @@ -0,0 +1,29 @@ +import click +import sys +from utils.logger import Logger +from config.config import Settings +from commands.common import common + +logger = Logger() + +@click.command("get") +@common +def get_command(input_json, scope, exist, updateAutomatically, updateFrequency): + """Gets the current state of a tstoy configuration file.""" + try: + data = Settings.validate( + input_json, scope, exist, updateAutomatically, updateFrequency, 'get', logger + ) + + settings = Settings.from_dict(data) + + result_settings, err = Settings.get_current_state(settings, logger) + if err: + logger.error(f"Failed to get settings: {err}", "get_command") + sys.exit(1) + + result_settings.print_config() + + except Exception as 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 new file mode 100644 index 0000000..684533e --- /dev/null +++ b/samples/python/resources/first/src/config/config.py @@ -0,0 +1,430 @@ +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 + + +@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): + 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: + 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 + + @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: + 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 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"} + + 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) + \ 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..d996a01 --- /dev/null +++ b/samples/python/resources/first/src/main.py @@ -0,0 +1,4 @@ +from commands.root import cli + +if __name__ == "__main__": + cli() \ No newline at end of file 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..8232fa5 --- /dev/null +++ b/samples/python/resources/first/src/schema/schema.py @@ -0,0 +1,50 @@ +import jsonschema +import json +import click +import sys + +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", + "default": True, + }, + "updateFrequency": { + "title": "Update check frequency", + "description": "Indicates how many days TSToy should wait before checking for updates.", + "type": "integer", + "minimum": 1, + "maximum": 180, + "default": 90, + }, + } +} + +def validate_resource(instance): + 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(): + return json.dumps(RESOURCE_SCHEMA, separators=(',', ':')) 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..009748d --- /dev/null +++ b/samples/python/resources/first/tests/acceptance.tests.ps1 @@ -0,0 +1,153 @@ +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 - 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 + $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[1] | Should -BeLike '*"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 + $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 + + & $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