diff --git a/.coverage b/.coverage deleted file mode 100644 index 8686e8f..0000000 Binary files a/.coverage and /dev/null differ diff --git a/.gitignore b/.gitignore index e095838..aa93aa8 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,18 @@ __pycache__/ build/ .penify/ .penify/* +.env/ +*.env +*.DS_Store +*.log +*.sqlite3 +*.db +.env +coverage.xml +*.coverage +*.cover +*.egg +*.egg-info +*.whl +*.zip +.coverage \ No newline at end of file diff --git a/Pipfile b/Pipfile new file mode 100644 index 0000000..8e6917b --- /dev/null +++ b/Pipfile @@ -0,0 +1,19 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] +requests = ">=2.25.0" +gitpython = ">=3.1.0" +tqdm = ">=4.62.0" +python-dotenv = ">=1.0.0" +pytest = ">=7.0.0" +pytest-cov = ">=4.0.0" +coverage = ">=6.0.0" +coverage-badge = ">=1.1.0" + +[dev-packages] + +[requires] +python_version = "3.13" diff --git a/coverage.xml b/coverage.xml deleted file mode 100644 index 056bb8b..0000000 --- a/coverage.xml +++ /dev/null @@ -1,1376 +0,0 @@ - - - - - - /Users/sumansaurabh/Documents/my-startup/penify-cli/penify_hook - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/penify_hook/commands/auth_commands.py b/penify_hook/commands/auth_commands.py index b8b4a1c..4107525 100644 --- a/penify_hook/commands/auth_commands.py +++ b/penify_hook/commands/auth_commands.py @@ -4,25 +4,63 @@ import socketserver import urllib.parse import random +import os from threading import Thread from pathlib import Path -from ..api_client import APIClient def save_credentials(api_key): - """Save or update the API keys in the .penify file in the user's home - directory. - + """ + Save the token and API keys based on priority: + 1. .env file in Git repo root (if in a git repo) + 2. .penify file in home directory (global fallback) + Args: - api_key (str): The new API key to be saved or updated. - + api_key: The API key to save + Returns: - bool: if the credentials were successfully saved, False otherwise. + bool: True if saved successfully, False otherwise """ + # Try to save in .env file in git repo first + try: + from ..utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + + if repo_root: + # We're in a git repo, save to .env file + env_file = Path(repo_root) / '.env' + try: + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update API token + env_content['PENIFY_API_TOKEN'] = api_key + + # Write back to .env file + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + + print(f"API token saved to {env_file}") + return True + except Exception as e: + print(f"Error saving to .env file: {str(e)}") + # Fall back to saving in .penify global config + except Exception as e: + print(f"Error finding git repository: {str(e)}") + + # Fall back to global .penify file in home directory home_dir = Path.home() penify_file = home_dir / '.penify' - - # if the file already exists, add the new api key to the existing file + # If the file already exists, add the new api key to the existing file if penify_file.exists(): with open(penify_file, 'r') as f: credentials = json.load(f) @@ -35,6 +73,7 @@ def save_credentials(api_key): try: with open(penify_file, 'w') as f: json.dump(credentials, f) + print(f"API token saved to global config {penify_file}") return True except Exception as e: print(f"Error saving credentials: {str(e)}") @@ -102,6 +141,7 @@ def do_GET(self): self.wfile.write(response.encode()) print(f"\nLogin successful! Fetching API keys...") + from ..api_client import APIClient api_key = APIClient(api_url, None, token).get_api_key() if api_key: save_credentials(api_key) diff --git a/penify_hook/commands/config_commands.py b/penify_hook/commands/config_commands.py index 518042a..540aac5 100644 --- a/penify_hook/commands/config_commands.py +++ b/penify_hook/commands/config_commands.py @@ -8,7 +8,61 @@ from pathlib import Path from threading import Thread import logging -from penify_hook.utils import recursive_search_git_folder +import sys +from typing import Dict, Any, Optional, Union + +# Try to import dotenv, but don't fail if it's not available +try: + from dotenv import load_dotenv + DOTENV_AVAILABLE = True +except ImportError: + DOTENV_AVAILABLE = False + + +def load_env_files() -> None: + """ + Load environment variables from .env files in various locations, + with proper priority (later files override earlier ones): + 1. User home directory .env (lowest priority) + 2. Git repo root directory .env (if in a git repo) + 3. Current directory .env (highest priority) + + This function is called when the module is imported, ensuring env variables + are available throughout the application lifecycle. + """ + if not DOTENV_AVAILABLE: + logging.warning("python-dotenv is not installed. .env file loading is disabled.") + logging.warning("Run 'pip install python-dotenv' to enable .env file support.") + return + + # Load from user home directory (lowest priority) + try: + home_env = Path.home() / '.env' + if home_env.exists(): + load_dotenv(dotenv_path=home_env, override=False) + except Exception as e: + logging.warning(f"Failed to load .env from home directory: {str(e)}") + + # Load from Git repo root (medium priority) + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + if repo_root and repo_root != str(Path.home()): + repo_env = Path(repo_root) / '.env' + if repo_env.exists() and repo_env != home_env: + load_dotenv(dotenv_path=repo_env, override=True) + except Exception as e: + logging.warning(f"Failed to load .env from Git repo: {str(e)}") + + # Load from current directory (highest priority) + current_env = Path(os.getcwd()) / '.env' + if current_env.exists() and (not repo_root or current_env != Path(repo_root) / '.env'): + load_dotenv(dotenv_path=current_env, override=True) + + +# Load environment variables when module is imported +load_env_files() def get_penify_config() -> Path: @@ -23,6 +77,7 @@ def get_penify_config() -> Path: Path: The path to the `config.json` file within the `.penify` directory. """ current_dir = os.getcwd() + from penify_hook.utils import recursive_search_git_folder home_dir = recursive_search_git_folder(current_dir) @@ -45,138 +100,195 @@ def get_penify_config() -> Path: with open(penify_dir / 'config.json', 'w') as f: json.dump({}, f) return penify_dir / 'config.json' - - -def save_llm_config(model, api_base, api_key): - """Save LLM configuration settings in the .penify file. - It reads the existing configuration from the .penify file if it exists, - updates or adds the LLM configuration with the provided model, API base, - and API key, and then writes the updated configuration back to the file. +def get_env_var_or_default(env_var: str, default: Any = None) -> Any: + """ + Get environment variable or return default value. + Args: - model (str): The name of the language model. - api_base (str): The base URL for the API. - api_key (str): The API key for authentication. - + env_var: The environment variable name + default: Default value if environment variable is not set + Returns: - bool: True if the LLM configuration was successfully saved, False otherwise. + Value of the environment variable or default """ + return os.environ.get(env_var, default) + - penify_file = get_penify_config() +def save_llm_config(model, api_base, api_key): + """ + Save LLM configuration settings to .env file. - config = {} - if penify_file.exists(): - try: - with open(penify_file, 'r') as f: - config = json.load(f) - except (json.JSONDecodeError, IOError, OSError) as e: - print(f"Error reading configuration file: {str(e)}") - # Continue with empty config - - # Update or add LLM configuration - config['llm'] = { - 'model': model, - 'api_base': api_base, - 'api_key': api_key - } + This function saves LLM configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env + """ + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + + # Try to find Git repo root + try: + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update LLM configuration + env_content['PENIFY_LLM_MODEL'] = model + env_content['PENIFY_LLM_API_BASE'] = api_base + env_content['PENIFY_LLM_API_KEY'] = api_key + + # Write back to .env file try: - with open(penify_file, 'w') as f: - json.dump(config, f) - print(f"LLM configuration saved to {penify_file}") + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"LLM configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + return True except Exception as e: print(f"Error saving LLM configuration: {str(e)}") return False -def save_jira_config(url, username, api_token): - """Save JIRA configuration settings in the .penify file. - - This function reads existing JIRA configuration from the .penify file, - updates or adds new JIRA configuration details, and writes it back to - the file. - - Args: - url (str): The URL of the JIRA instance. - username (str): The username for accessing the JIRA instance. - api_token (str): The API token used for authentication. - Returns: - bool: True if the configuration was successfully saved, False otherwise. +def save_jira_config(url, username, api_token): """ - from penify_hook.utils import recursive_search_git_folder - - home_dir = Path.home() - penify_file = home_dir / '.penify' + Save JIRA configuration settings to .env file. - config = {} - if penify_file.exists(): - try: - with open(penify_file, 'r') as f: - config = json.load(f) - except json.JSONDecodeError: - pass - - # Update or add JIRA configuration - config['jira'] = { - 'url': url, - 'username': username, - 'api_token': api_token - } + This function saves JIRA configuration in the following priority: + 1. Git repo root .env (if inside a git repo) + 2. User home directory .env + """ + from pathlib import Path + import os + + if not DOTENV_AVAILABLE: + print("python-dotenv is not installed. Run 'pip install python-dotenv' to enable .env file support.") + return False + # Try to find Git repo root try: - with open(penify_file, 'w') as f: - json.dump(config, f) - print(f"JIRA configuration saved to {penify_file}") + from penify_hook.utils import recursive_search_git_folder + current_dir = os.getcwd() + repo_root = recursive_search_git_folder(current_dir) + env_file = Path(repo_root) / '.env' if repo_root else Path.home() / '.env' + except Exception as e: + print(f"Failed to determine Git repo root: {str(e)}") + env_file = Path.home() / '.env' + + # Read existing .env content + env_content = {} + if env_file.exists(): + with open(env_file, 'r') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + env_content[key.strip()] = value.strip() + + # Update JIRA configuration + env_content['PENIFY_JIRA_URL'] = url + env_content['PENIFY_JIRA_USER'] = username + env_content['PENIFY_JIRA_TOKEN'] = api_token + + # Write back to .env file + try: + with open(env_file, 'w') as f: + for key, value in env_content.items(): + f.write(f"{key}={value}\n") + print(f"JIRA configuration saved to {env_file}") + + # Reload environment variables to make changes immediately available + if DOTENV_AVAILABLE: + from dotenv import load_dotenv + load_dotenv(dotenv_path=env_file, override=True) + return True except Exception as e: print(f"Error saving JIRA configuration: {str(e)}") return False -def get_llm_config(): - """Retrieve LLM configuration from the .penify file. - - This function reads the .penify configuration file and extracts the LLM - settings. If the file does not exist or contains invalid JSON, it - returns an empty dictionary. +def get_llm_config() -> Dict[str, str]: + """ + Get LLM configuration from environment variables. + + Environment variables: + - PENIFY_LLM_MODEL: Model name + - PENIFY_LLM_API_BASE: API base URL + - PENIFY_LLM_API_KEY: API key + Returns: - dict: A dictionary containing the LLM configuration, or an empty dictionary if - the file is missing or invalid. + dict: Configuration dictionary with model, api_base, and api_key """ - config_file = get_penify_config() - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - return config.get('llm', {}) - except (json.JSONDecodeError, Exception) as e: - print(f"Error reading .penify config file: {str(e)}") + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() - return {} - -def get_jira_config(): - """Get JIRA configuration from the .penify file. + # Get values from environment variables + config = { + 'model': get_env_var_or_default('PENIFY_LLM_MODEL', ''), + 'api_base': get_env_var_or_default('PENIFY_LLM_API_BASE', ''), + 'api_key': get_env_var_or_default('PENIFY_LLM_API_KEY', '') + } + + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config - This function reads the JIRA configuration from a JSON file specified in - the .penify file. If the .penify file exists and contains valid JSON - with a 'jira' key, it returns the corresponding configuration. - Otherwise, it returns an empty dictionary. +def get_jira_config() -> Dict[str, str]: + """ + Get JIRA configuration from environment variables. + + Environment variables: + - PENIFY_JIRA_URL: JIRA URL + - PENIFY_JIRA_USER: JIRA username + - PENIFY_JIRA_TOKEN: JIRA API token + Returns: - dict: The JIRA configuration or an empty dictionary if not found or invalid. + dict: Configuration dictionary with url, username, and api_token """ - config_file = get_penify_config() - if config_file.exists(): - try: - with open(config_file, 'r') as f: - config = json.load(f) - return config.get('jira', {}) - except (json.JSONDecodeError, Exception) as e: - print(f"Error reading .penify config file: {str(e)}") + # Ensure environment variables are loaded + if DOTENV_AVAILABLE: + load_env_files() + + # Get values from environment variables + config = { + 'url': get_env_var_or_default('PENIFY_JIRA_URL', ''), + 'username': get_env_var_or_default('PENIFY_JIRA_USER', ''), + 'api_token': get_env_var_or_default('PENIFY_JIRA_TOKEN', '') + } - return {} + # Remove empty values + config = {k: v for k, v in config.items() if v} + + return config + def config_llm_web(): """Open a web browser interface for configuring LLM settings. @@ -308,6 +420,7 @@ def log_message(self, format, *args): print("Configuration completed.") + def config_jira_web(): """Open a web browser interface for configuring JIRA settings. @@ -436,19 +549,27 @@ def log_message(self, format, *args): print("Configuration completed.") -def get_token(): - """Get the token based on priority from environment variables or - configuration files. +def get_token() -> Optional[str]: + """ + Get the API token based on priority: + 1. Environment variable PENIFY_API_TOKEN from any .env file + 2. Config file 'api_keys' value + Returns: - str: The API token if found, otherwise None. + str or None: API token if found, None otherwise """ - import os - env_token = os.getenv('PENIFY_API_TOKEN') + # Ensure environment variables are loaded from all .env files + if DOTENV_AVAILABLE: + load_env_files() + + # Check environment variable first + env_token = get_env_var_or_default('PENIFY_API_TOKEN') if env_token: return env_token - config_file = Path.home() / '.penify' + # Check config file + config_file = get_penify_config() if config_file.exists(): try: with open(config_file, 'r') as f: diff --git a/penify_hook/config_command.py b/penify_hook/config_command.py index 1d9a6ab..9f75331 100644 --- a/penify_hook/config_command.py +++ b/penify_hook/config_command.py @@ -18,23 +18,23 @@ def setup_config_parser(parent_parser): parser = parent_parser.add_subparsers(title="config_type", dest="config_type") # Config subcommand: llm - llm_config_parser = parser.add_parser("llm", help="Configure LLM settings.") + llm_config_parser = parser.add_parser("llm-cmd", help="Configure LLM settings.") llm_config_parser.add_argument("--model", required=True, help="LLM model to use") llm_config_parser.add_argument("--api-base", help="API base URL for the LLM service") llm_config_parser.add_argument("--api-key", help="API key for the LLM service") # Config subcommand: llm-web - parser.add_parser("llm-web", help="Configure LLM settings through a web interface") + parser.add_parser("llm", help="Configure LLM settings through a web interface") # Config subcommand: jira - jira_config_parser = parser.add_parser("jira", help="Configure JIRA settings.") + jira_config_parser = parser.add_parser("jira-cmd", help="Configure JIRA settings.") jira_config_parser.add_argument("--url", required=True, help="JIRA base URL") jira_config_parser.add_argument("--username", required=True, help="JIRA username or email") jira_config_parser.add_argument("--api-token", required=True, help="JIRA API token") jira_config_parser.add_argument("--verify", action="store_true", help="Verify JIRA connection") # Config subcommand: jira-web - parser.add_parser("jira-web", help="Configure JIRA settings through a web interface") + parser.add_parser("jira", help="Configure JIRA settings through a web interface") # Add all other necessary arguments for config command @@ -53,20 +53,23 @@ def handle_config(args): """ # Only import dependencies needed for config functionality here - from penify_hook.commands.config_commands import save_llm_config - from penify_hook.jira_client import JiraClient # Import moved here - from penify_hook.commands.config_commands import config_jira_web, config_llm_web, save_jira_config + + - if args.config_type == "llm": + if args.config_type == "llm-cmd": + from penify_hook.commands.config_commands import save_llm_config save_llm_config(args.model, args.api_base, args.api_key) print(f"LLM configuration set: Model={args.model}, API Base={args.api_base or 'default'}") - elif args.config_type == "llm-web": + elif args.config_type == "llm": + from penify_hook.commands.config_commands import config_llm_web config_llm_web() - elif args.config_type == "jira": + elif args.config_type == "jira-cmd": + from penify_hook.commands.config_commands import save_jira_config save_jira_config(args.url, args.username, args.api_token) print(f"JIRA configuration set: URL={args.url}, Username={args.username}") + from penify_hook.jira_client import JiraClient # Import moved here # Verify connection if requested if args.verify: @@ -83,11 +86,12 @@ def handle_config(args): else: print("JIRA package not installed. Cannot verify connection.") - elif args.config_type == "jira-web": + elif args.config_type == "jira": + from penify_hook.commands.config_commands import config_jira_web config_jira_web() else: - print("Please specify a config type: llm, llm-web, jira, or jira-web") + print("Please specify a config type: llm, jira") return 1 return 0 diff --git a/requirements.txt b/requirements.txt index 0679314..216bc40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ requests>=2.25.0 gitpython>=3.1.0 tqdm>=4.62.0 +python-dotenv>=1.0.0 # Testing requirements pytest>=7.0.0