Skip to content

Commit 0a1dd60

Browse files
authored
Merge pull request #358 from ma10/yaml2sheet-revise-config-loading-20250613
yaml2sheet: improve config file handling.
2 parents b81cd60 + cbe1e12 commit 0a1dd60

File tree

3 files changed

+146
-99
lines changed

3 files changed

+146
-99
lines changed

tools/scripts/yaml2sheet/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "yaml2sheet"
7-
version = "0.1.3"
7+
version = "0.1.4"
88
description = "Generate checklist in Google Sheets from YAML data"
99
readme = "README.md"
1010
authors = [

tools/scripts/yaml2sheet/src/yaml2sheet/config_loader.py

Lines changed: 91 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from typing import Dict, Any, Optional, Literal, Union
44
import logging
55
from pathlib import Path
6-
from pydantic import BaseModel, Field, model_validator, field_validator, ValidationError
6+
from pydantic import BaseModel, Field, model_validator, field_validator, ValidationError, ValidationInfo
77
import yaml
88
import toml
99
import configparser
@@ -45,6 +45,25 @@ class ApplicationConfig(BaseModel):
4545
basedir: Optional[Path] = Field(None, description="Base directory for YAML files")
4646
base_url: str = Field(default="https://a11y-guidelines.freee.co.jp", description="Base URL for documentation")
4747

48+
@model_validator(mode='after')
49+
def resolve_credential_paths(self, info: ValidationInfo) -> 'ApplicationConfig':
50+
"""Resolve credential and token paths relative to the config file."""
51+
# load_configurationからcontext経由で設定ファイルのパスを取得
52+
config_file_path = info.context.get('config_file_path') if info.context else None
53+
54+
if config_file_path:
55+
config_dir = config_file_path.parent
56+
57+
# 1. credentials_path が相対パスの場合、設定ファイルのディレクトリからの相対パスとして解決
58+
if not self.credentials_path.is_absolute():
59+
self.credentials_path = config_dir / self.credentials_path
60+
61+
# 2. token_path が相対パスの場合、同様に解決
62+
if not self.token_path.is_absolute():
63+
self.token_path = config_dir / self.token_path
64+
65+
return self
66+
4867
def get_base_url(self, cmd_base_url: Optional[str] = None) -> str:
4968
"""Get base URL for documentation, resolving from command line or config
5069
@@ -325,93 +344,81 @@ def get_config_loader(config_path: Path) -> ConfigLoader:
325344
return loaders[ext]
326345

327346
def find_config_file(config_path: Optional[Union[str, Path]] = None) -> Path:
328-
"""Find configuration file from specified path or default locations
329-
347+
"""Find configuration file from specified path or default locations.
348+
349+
Search logic:
350+
1. If -c option is used:
351+
a. If absolute path, use it directly.
352+
b. If relative path, search only in the current working directory.
353+
2. If -c option is NOT used, search in order:
354+
a. In `${HOME}/.config/freee_a11y_gl/` for `yaml2sheet.{yaml,yml,ini,toml}`
355+
b. In the current working directory for `.yaml2sheet.{yaml,yml,ini,toml}`
356+
330357
Args:
331-
config_path: Command-line specified configuration file path
332-
358+
config_path: Command-line specified configuration file path.
359+
333360
Returns:
334-
Path: Absolute path to found configuration file
335-
361+
Path: Absolute path to the found configuration file.
362+
336363
Raises:
337-
FileNotFoundError: If configuration file not found
364+
FileNotFoundError: If configuration file is not found.
338365
"""
339-
try:
340-
# Convert to Path if string provided
341-
config_path_obj = Path(config_path) if config_path else None
342-
except Exception as e:
343-
raise ValueError(f"Invalid config path: {e}")
344-
345-
# If path specified with -c option
346-
if config_path_obj:
347-
if config_path_obj.is_absolute():
348-
# Use absolute path as is
349-
if not config_path_obj.exists():
350-
raise FileNotFoundError(f"Specified configuration file not found: {config_path_obj}")
351-
return validate_readable_file(config_path_obj)
366+
# When -c option is specified, use the provided path directly
367+
if config_path:
368+
path_obj = Path(config_path)
369+
370+
# a. When the path is absolute
371+
if path_obj.is_absolute():
372+
if not path_obj.exists():
373+
raise FileNotFoundError(f"Specified configuration file not found: {path_obj}")
374+
return validate_readable_file(path_obj)
375+
376+
# b. When the path is relative (only serach in current directory)
352377
else:
353-
# For relative path, search in:
354-
# 1. Current working directory
355-
# 2. Script directory
356-
search_dirs = [
357-
Path.cwd(), # Current working directory first
358-
Path(__file__).parent.absolute() # Script directory second
359-
]
360-
361-
tried_paths = []
362-
for dir_path in search_dirs:
363-
try_path = dir_path / config_path_obj
364-
tried_paths.append(try_path)
365-
if try_path.exists():
366-
return validate_readable_file(try_path)
367-
368-
raise FileNotFoundError(
369-
"Specified configuration file not found. Searched in:\n" +
370-
"\n".join(f"- {p}" for p in tried_paths)
371-
)
372-
373-
# File types in order of precedence
374-
config_types = ["yaml", "yml", "toml", "ini"]
375-
search_dirs = [
376-
Path.cwd(), # Current working directory first
377-
Path(__file__).parent.absolute() # Script directory second
378+
search_path = Path.cwd() / path_obj
379+
if search_path.exists():
380+
return validate_readable_file(search_path)
381+
else:
382+
raise FileNotFoundError(
383+
"Specified configuration file not found in the current directory.\n"
384+
f"- Searched for: {search_path}"
385+
)
386+
387+
# Default search logic when -c option is not used
388+
search_locations = [
389+
{
390+
"dir": Path.home() / ".config" / "freee_a11y_gl",
391+
"basename": "yaml2sheet"
392+
},
393+
{
394+
"dir": Path.cwd(),
395+
"basename": ".yaml2sheet"
396+
}
378397
]
379398

380-
# First check for config.* in current directory
381-
for ext in config_types:
382-
config_path = Path.cwd() / f"config.{ext}"
383-
if config_path.exists():
384-
logger.debug(f"Found config file in current directory: {config_path}")
385-
return validate_readable_file(config_path)
386-
387-
# Then check script directory
388-
script_dir = Path(__file__).parent.absolute()
389-
for ext in config_types:
390-
config_path = script_dir / f"config.{ext}"
391-
if config_path.exists():
392-
logger.debug(f"Found config file in script directory: {config_path}")
393-
return validate_readable_file(config_path)
394-
395-
# Build search paths list for error message, maintaining search order
396-
search_paths = [
397-
Path.cwd() / f"config.{ext}"
398-
for ext in config_types
399-
] + [
400-
Path(__file__).parent.absolute() / f"config.{ext}"
401-
for ext in config_types
402-
]
399+
config_types = ["yaml", "yml", "ini", "toml"]
403400

401+
tried_paths = []
402+
for loc in search_locations:
403+
if not loc["dir"].is_dir():
404+
continue
405+
for ext in config_types:
406+
path_to_check = loc["dir"] / f"{loc['basename']}.{ext}"
407+
tried_paths.append(path_to_check)
408+
if path_to_check.exists():
409+
logger.debug(f"Found config file: {path_to_check}")
410+
return validate_readable_file(path_to_check)
411+
412+
# If no config file found, raise FileNotFoundError
404413
raise FileNotFoundError(
405414
"No configuration file found. Searched in order of precedence:\n" +
406-
"\n".join(f"- {p}" for p in search_paths) +
407-
"\n\nTo create a config file, save as 'config.yaml' with the following content:\n" +
415+
"\n".join(f"- {p}" for p in tried_paths) +
416+
"\n\nTo create a config file, save as "
417+
f"'{Path.home() / '.config' / 'freee_a11y_gl' / 'yaml2sheet.yaml'}' "
418+
"with the following content:\n" +
408419
"---\n" +
409-
"credentials_path: credentials.json\n" +
410-
"token_path: token.json\n" +
411420
"development_spreadsheet_id: YOUR_DEV_SPREADSHEET_ID\n" +
412-
"production_spreadsheet_id: YOUR_PROD_SPREADSHEET_ID\n" +
413-
"log_level: INFO\n" +
414-
"base_url: https://a11y-guidelines.freee.co.jp\n"
421+
"production_spreadsheet_id: YOUR_PROD_SPREADSHEET_ID\n"
415422
)
416423

417424
def load_configuration(config_path: Optional[Union[str, Path]] = None) -> ApplicationConfig:
@@ -437,7 +444,12 @@ def load_configuration(config_path: Optional[Union[str, Path]] = None) -> Applic
437444

438445
# Validate and return config
439446
try:
440-
validated_config = ApplicationConfig.model_validate(config_data)
447+
validation_context = {"config_file_path": actual_config_path}
448+
validated_config = ApplicationConfig.model_validate(
449+
config_data,
450+
context=validation_context
451+
)
452+
441453
return validated_config
442454
except ValidationError as e:
443455
logger.error(f"Configuration validation error: {e}")
@@ -451,13 +463,13 @@ def create_default_config(output_path: Optional[Union[str, Path]] = None) -> Pat
451463
"""Create a default configuration file
452464
453465
Args:
454-
output_path: Path to save configuration file (defaults to ./config.yaml)
466+
output_path: Path to save configuration file (defaults to ./yaml2sheet.yaml)
455467
456468
Returns:
457469
Path: Path to created configuration file
458470
"""
459471
if output_path is None:
460-
output_path = Path.cwd() / "config.yaml"
472+
output_path = Path.cwd() / "yaml2sheet.yaml"
461473
else:
462474
output_path = Path(output_path)
463475

tools/scripts/yaml2sheet/src/yaml2sheet/yaml2sheet.py

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from pathlib import Path
77
from .auth import GoogleAuthManager
88
from .sheet_generator import ChecklistSheetGenerator
9-
from .config_loader import load_configuration, ApplicationConfig
9+
from .config_loader import load_configuration, ApplicationConfig, create_default_config
1010
from googleapiclient.errors import HttpError
1111
from google.oauth2.credentials import Credentials
1212

@@ -23,6 +23,12 @@ def parse_args() -> argparse.Namespace:
2323
description='Generate checklist in Google Sheets',
2424
formatter_class=argparse.ArgumentDefaultsHelpFormatter
2525
)
26+
27+
parser.add_argument(
28+
'--create-config',
29+
action='store_true',
30+
help='Create a default configuration file in the current directory and exit.'
31+
)
2632

2733
parser.add_argument(
2834
'-c', '--config',
@@ -127,31 +133,60 @@ def main() -> int:
127133
if args.verbose:
128134
setup_logging(logging.DEBUG)
129135
logger.debug("Verbose logging enabled")
136+
137+
if args.create_config:
138+
try:
139+
output_path = Path.cwd() / "yaml2sheet.yaml"
140+
created_path = create_default_config(output_path)
141+
logger.info(f"Default configuration file created at: {created_path}")
142+
print(f"Default configuration file created at: {created_path}")
143+
print("Please edit this file with your settings and run the command again.")
144+
return 0
145+
except Exception as e:
146+
logger.error(f"Failed to create configuration file: {e}")
147+
return 1
130148

131149
try:
132150
# Load configuration
133151
logger.debug(f"Loading configuration from {args.config if args.config else 'default locations'}")
134152
config = load_configuration(args.config)
135-
136-
# Update log level from config if not in verbose mode
137-
if not args.verbose:
138-
current_level = logging.getLogger().getEffectiveLevel()
139-
config_level = config.get_log_level()
140-
if current_level != config_level:
141-
logger.debug(f"Updating log level from {current_level} to {config_level}")
142-
setup_logging(config_level)
143-
144-
# Log which environment we're using
145-
env_type = "production" if args.production else "development"
146-
logger.info(f"Using {env_type} environment")
147-
148-
# Get authentication
149-
credentials = get_credentials(config)
150-
if credentials is None:
153+
154+
except FileNotFoundError:
155+
logger.warning("Configuration file not found.")
156+
if not args.config and sys.stdout.isatty():
157+
try:
158+
response = input("Would you like to create a default configuration file (config.yaml)? [y/N]: ")
159+
if response.lower().strip() == 'y':
160+
output_path = Path.cwd() / "yaml2sheet.yaml"
161+
created_path = create_default_config(output_path)
162+
print(f"\nDefault configuration file created at: {created_path}")
163+
print("Please edit this file with your settings and run the command again.")
164+
return 0
165+
else:
166+
print("Aborting. Please create a configuration file manually.")
167+
return 1
168+
except (EOFError, KeyboardInterrupt):
169+
print("\nOperation cancelled.")
170+
return 1
171+
else:
172+
logger.error("No configuration file found at the specified path or default locations.")
151173
return 1
152174

153-
except Exception as e:
154-
logger.error(f"Configuration error: {e}")
175+
# Update log level from config if not in verbose mode
176+
if not args.verbose:
177+
current_level = logging.getLogger().getEffectiveLevel()
178+
config_level = config.get_log_level()
179+
if current_level != config_level:
180+
logger.debug(f"Updating log level from {current_level} to {config_level}")
181+
setup_logging(config_level)
182+
183+
# Log which environment we're using
184+
env_type = "production" if args.production else "development"
185+
logger.info(f"Using {env_type} environment")
186+
187+
# Get authentication
188+
credentials = get_credentials(config)
189+
if credentials is None:
155190
return 1
156191

157192
try:

0 commit comments

Comments
 (0)