diff --git a/pyproject.toml b/pyproject.toml index 726b9cc..0b8f08e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yamlcli" -version = "0.2.0" +version = "1.0.0" description = "Convert YAML files to JSON and vice versa" requires-python = ">=3.10,<4.0" readme = "README.md" @@ -9,10 +9,11 @@ authors = [ ] dependencies = [ "pyyaml", + "typer>=0.20.0", ] [project.scripts] -yamlcli = "yamlcli.cli:main" +yamlcli = "yamlcli:main" [build-system] requires = ["uv_build>=0.9.11,<0.10.0"] diff --git a/src/yamlcli/__init__.py b/src/yamlcli/__init__.py index e69de29..ef6e3ef 100644 --- a/src/yamlcli/__init__.py +++ b/src/yamlcli/__init__.py @@ -0,0 +1,3 @@ +from .cli import app, main + +__all__ = ["app", "main"] diff --git a/src/yamlcli/cli.py b/src/yamlcli/cli.py index 365e23a..0ba4798 100644 --- a/src/yamlcli/cli.py +++ b/src/yamlcli/cli.py @@ -1,72 +1,77 @@ import sys -import yaml import json -import argparse - +from pathlib import Path +import yaml +import typer -class RegmonkeyDumper(yaml.Dumper): - def increase_indent(self, flow=False, indentless=False): - return super(RegmonkeyDumper, self).increase_indent(flow, False) +from yamlcli.yamlcli_core import yaml_to_json, json_to_yaml +from yamlcli.library.version import __version__ +app = typer.Typer( + add_completion=False, + help=( + "A simple command-line tool to convert between YAML and JSON formats.\n\n" + "Features:\n" + "- Convert YAML files to JSON\n" + "- Convert JSON files to YAML\n" + "- Support for custom JSON indentation\n" + "- Robust error handling for invalid files or malformed data" + ), +) -def yaml_to_json(file_path, indent): - with open(file_path, "r") as f: - data = yaml.safe_load(f) +def version_callback(value: bool): + if value: + print(f"yamlcli {__version__}") + print(f"Python {sys.version.split()[0]}") + raise typer.Exit() - if indent <= 0: - print(json.dumps(data)) - else: - print(json.dumps(data, indent=indent)) +@app.command() +def converter( + file: str = typer.Argument(..., help="Input file path"), + to_json: bool = typer.Option(False, "--to-json", help="Convert YAML → JSON"), + to_yaml: bool = typer.Option(False, "--to-yaml", help="Convert JSON → YAML"), + indent: int = typer.Option(2, "--indent", help="Indentation level (default: 2)"), + version: bool = typer.Option( + False, + "--version", + "-v", + help="Show version information and exit.", + callback=version_callback, + is_eager=True, + ), +): + """Main entry point for yamlcli""" + # 排他チェック(pytest 要求) + if (not to_json and not to_yaml) or (to_json and to_yaml): + typer.echo("Error: Specify exactly one of --to-json or --to-yaml", err=True) + raise typer.Exit(code=1) -def json_to_yaml(file_path, indent): - with open(file_path, "r") as f: - data = json.load(f) + if not Path(file).exists(): + typer.echo(f"Error: File not found - {file}", err=True) + raise typer.Exit(code=1) - if indent <= 0: - print(yaml.safe_dump(data, sort_keys=False)) - else: - # Dump YAML with 2-space indentation for lists - print( - yaml.dump( - data, - Dumper=RegmonkeyDumper, - sort_keys=False, - default_flow_style=False, - indent=indent, - ) - ) + try: + if to_yaml: + json_to_yaml(file, indent) + else: + yaml_to_json(file, indent) + except yaml.YAMLError as e: + typer.echo(f"Error: Parsing failed - {e}", err=True) + raise typer.Exit(code=1) -def main(): - parser = argparse.ArgumentParser(description="Convert YAML ↔ JSON") - parser.add_argument("file", help="Input file path") - parser.add_argument("--to-json", action="store_true", help="Convert YAML to JSON") - parser.add_argument("--to-yaml", action="store_true", help="Convert JSON to YAML") - parser.add_argument( - "--indent", - type=int, - default=2, - help="Indentation level for JSON output (default: 2)", - ) - args = parser.parse_args() + except json.JSONDecodeError as e: + typer.echo(f"Error: Parsing failed - {e}", err=True) + raise typer.Exit(code=1) - if args.to_json == args.to_yaml: - print("Error: Specify exactly one of --to-json or --to-yaml", file=sys.stderr) - sys.exit(1) - - try: - if args.to_yaml: - json_to_yaml(args.file, args.indent) - else: - yaml_to_json(args.file, args.indent) except FileNotFoundError: - print(f"Error: File not found - {args.file}", file=sys.stderr) - sys.exit(1) - except (yaml.YAMLError, json.JSONDecodeError) as e: - print(f"Error: Parsing failed - {e}", file=sys.stderr) - sys.exit(1) + typer.echo(f"Error: File not found - {file}", err=True) + raise typer.Exit(code=1) +def main(): + app() + if __name__ == "__main__": - main() + app() diff --git a/src/yamlcli/library/__init__.py b/src/yamlcli/library/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/yamlcli/library/helper_func.py b/src/yamlcli/library/helper_func.py new file mode 100644 index 0000000..d1082c4 --- /dev/null +++ b/src/yamlcli/library/helper_func.py @@ -0,0 +1,37 @@ +from pathlib import Path +import tomllib +from importlib.metadata import version, PackageNotFoundError + + +def get_version() -> str: + """ + Retrieve the version of the installed package. + + Priority: + 1. Installed metadata (importlib.metadata.version) + 2. [DEBUG]: pyproject.toml in development mode + 3. [DEBUG]: fallback "0.0.0" + """ + + # 1. Installed package metadata + try: + return version("yamlcli") + except PackageNotFoundError: + pass + + # 2. [DEBUG]: Development mode: look for pyproject.toml + current = Path(__file__).resolve() + for parent in current.parents: + pyproject = parent / "pyproject.toml" + if pyproject.exists(): + try: + with pyproject.open("rb") as f: + data = tomllib.load(f) + project_version = data.get("project", {}).get("version") + if project_version: + return project_version + except Exception: + pass + + # 3. [DEBUG]: fallback + return "0.0.0" diff --git a/src/yamlcli/library/version.py b/src/yamlcli/library/version.py new file mode 100644 index 0000000..192567e --- /dev/null +++ b/src/yamlcli/library/version.py @@ -0,0 +1,3 @@ +from yamlcli.library.helper_func import get_version + +__version__ = get_version() diff --git a/src/yamlcli/yamlcli_core.py b/src/yamlcli/yamlcli_core.py new file mode 100644 index 0000000..c965beb --- /dev/null +++ b/src/yamlcli/yamlcli_core.py @@ -0,0 +1,36 @@ +import yaml # type: ignore[import-untyped] +import json + + +class RegmonkeyDumper(yaml.Dumper): + def increase_indent(self, flow=False, indentless=False): + return super(RegmonkeyDumper, self).increase_indent(flow, False) + + +def yaml_to_json(file_path, indent): + with open(file_path, "r") as f: + data = yaml.safe_load(f) + + if indent <= 0: + print(json.dumps(data)) + else: + print(json.dumps(data, indent=indent)) + + +def json_to_yaml(file_path, indent): + with open(file_path, "r") as f: + data = json.load(f) + + if indent <= 0: + print(yaml.safe_dump(data, sort_keys=False)) + else: + # Dump YAML with 2-space indentation for lists + print( + yaml.dump( + data, + Dumper=RegmonkeyDumper, + sort_keys=False, + default_flow_style=False, + indent=indent, + ) + ) diff --git a/tests/test_coremodule.py b/tests/test_coremodule.py new file mode 100644 index 0000000..6dc86bb --- /dev/null +++ b/tests/test_coremodule.py @@ -0,0 +1,125 @@ +import pytest +import json +import yaml +import tempfile +import os +from yamlcli.yamlcli_core import yaml_to_json, json_to_yaml + +@pytest.fixture +def sample_yaml_file(): + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".yaml") as f: + f.write(""" +name: John Doe +age: 30 +hobbies: + - reading + - coding +address: + street: 123 Main St + city: Example City +""") + yield f.name + os.unlink(f.name) + + +@pytest.fixture +def sample_json_file(): + with tempfile.NamedTemporaryFile(mode="w", delete=False, suffix=".json") as f: + json.dump( + { + "name": "John Doe", + "age": 30, + "hobbies": ["reading", "coding"], + "address": {"street": "123 Main St", "city": "Example City"}, + }, + f, + indent=2, + ) + yield f.name + os.unlink(f.name) + + +def test_yaml_to_json_basic(sample_yaml_file, capsys): + yaml_to_json(sample_yaml_file, indent=2) + captured = capsys.readouterr() + output = captured.out + + # Parse the output back to Python object for comparison + parsed_output = json.loads(output) + expected = { + "name": "John Doe", + "age": 30, + "hobbies": ["reading", "coding"], + "address": {"street": "123 Main St", "city": "Example City"}, + } + assert parsed_output == expected + + +def test_yaml_to_json_no_indent(sample_yaml_file, capsys): + yaml_to_json(sample_yaml_file, indent=0) + captured = capsys.readouterr() + output = captured.out + + # Verify it's a single line JSON + assert len(output.strip().split("\n")) == 1 + + # Verify content is correct + parsed_output = json.loads(output) + assert parsed_output["name"] == "John Doe" + assert parsed_output["age"] == 30 + + +def test_yaml_to_json_file_not_found(): + with pytest.raises(FileNotFoundError): + yaml_to_json("nonexistent.yaml", indent=2) + + +def test_yaml_to_json_invalid_yaml(tmp_path, capsys): + invalid_yaml = tmp_path / "invalid.yaml" + invalid_yaml.write_text( + """invalid: + - missing + indentation +""" + ) + + yaml_to_json(invalid_yaml, indent=2) + captured = capsys.readouterr() + output = captured.out + parsed_output = json.loads(output) + + # Since PyYAML is lenient, check output instead of expecting exception + assert parsed_output == {"invalid": ["missing indentation"]} + + +def test_json_to_yaml_basic(sample_json_file, capsys): + json_to_yaml(sample_json_file, indent=0) + output = capsys.readouterr().out + + # Parse the output back to Python object for comparison + parsed_output = yaml.safe_load(output) + expected = { + "name": "John Doe", + "age": 30, + "hobbies": ["reading", "coding"], + "address": {"street": "123 Main St", "city": "Example City"}, + } + assert parsed_output == expected + + +def test_json_to_yaml_file_not_found(): + with pytest.raises(FileNotFoundError): + json_to_yaml("nonexistent.json", indent=0) + + +def test_json_to_yaml_invalid_json(tmp_path, capsys): + invalid_json = tmp_path / "invalid.json" + invalid_json.write_text(""" + { + "invalid": "json", + missing: quotes + } + """) + + with pytest.raises(json.JSONDecodeError): + json_to_yaml(str(invalid_json), indent=0) diff --git a/tests/test_yamlcli.py b/tests/test_yamlcli.py index 1fe5b01..17f1973 100644 --- a/tests/test_yamlcli.py +++ b/tests/test_yamlcli.py @@ -5,8 +5,11 @@ import tempfile import os import runpy +from typer.testing import CliRunner -import yamlcli +from yamlcli.cli import app + +runner = CliRunner() @pytest.fixture @@ -43,173 +46,75 @@ def sample_json_file(): os.unlink(f.name) -def test_yaml_to_json_basic(sample_yaml_file, capsys): - yamlcli.yaml_to_json(sample_yaml_file, indent=2) - captured = capsys.readouterr() - output = captured.out - - # Parse the output back to Python object for comparison - parsed_output = json.loads(output) - expected = { - "name": "John Doe", - "age": 30, - "hobbies": ["reading", "coding"], - "address": {"street": "123 Main St", "city": "Example City"}, - } - assert parsed_output == expected - - -def test_yaml_to_json_no_indent(sample_yaml_file, capsys): - yamlcli.yaml_to_json(sample_yaml_file, indent=0) - captured = capsys.readouterr() - output = captured.out +def test_cli_to_json(sample_yaml_file): + result = runner.invoke(app, [sample_yaml_file, "--to-json"]) + assert result.exit_code == 0 - # Verify it's a single line JSON - assert len(output.strip().split("\n")) == 1 - - # Verify content is correct - parsed_output = json.loads(output) + parsed_output = json.loads(result.stdout) assert parsed_output["name"] == "John Doe" - assert parsed_output["age"] == 30 - - -def test_yaml_to_json_file_not_found(): - with pytest.raises(FileNotFoundError): - yamlcli.yaml_to_json("nonexistent.yaml", indent=2) - - -def test_yaml_to_json_invalid_yaml(tmp_path, capsys): - invalid_yaml = tmp_path / "invalid.yaml" - invalid_yaml.write_text( - """invalid: - - missing - indentation -""" - ) - - yamlcli.yaml_to_json(invalid_yaml, indent=2) - captured = capsys.readouterr() - output = captured.out - parsed_output = json.loads(output) - - # Since PyYAML is lenient, check output instead of expecting exception - assert parsed_output == {"invalid": ["missing indentation"]} - - -def test_json_to_yaml_basic(sample_json_file, capsys): - yamlcli.json_to_yaml(sample_json_file, indent=0) - output = capsys.readouterr().out - - # Parse the output back to Python object for comparison - parsed_output = yaml.safe_load(output) - expected = { - "name": "John Doe", - "age": 30, - "hobbies": ["reading", "coding"], - "address": {"street": "123 Main St", "city": "Example City"}, - } - assert parsed_output == expected -def test_json_to_yaml_file_not_found(): - with pytest.raises(FileNotFoundError): - yamlcli.json_to_yaml("nonexistent.json", indent=0) +def test_cli_to_yaml(sample_json_file): + result = runner.invoke(app, [sample_json_file, "--to-yaml"]) + assert result.exit_code == 0 - -def test_json_to_yaml_invalid_json(tmp_path, capsys): - invalid_json = tmp_path / "invalid.json" - invalid_json.write_text(""" - { - "invalid": "json", - missing: quotes - } - """) - - with pytest.raises(json.JSONDecodeError): - yamlcli.json_to_yaml(str(invalid_json), indent=0) - - -def test_cli_to_json(sample_yaml_file, capsys, monkeypatch): - monkeypatch.setattr(sys, "argv", ["yamlcli", sample_yaml_file, "--to-json"]) - yamlcli.main() - output = capsys.readouterr().out - parsed_output = json.loads(output) + parsed_output = yaml.safe_load(result.stdout) assert parsed_output["name"] == "John Doe" -def test_cli_to_yaml(sample_json_file, capsys, monkeypatch): - monkeypatch.setattr(sys, "argv", ["yamlcli", sample_json_file, "--to-yaml"]) - yamlcli.main() - output = capsys.readouterr().out - parsed_output = yaml.safe_load(output) - assert parsed_output["name"] == "John Doe" +def test_cli_no_conversion_flag(): + result = runner.invoke(app, ["file.yaml"]) + assert result.exit_code == 1 + assert "Error" in result.stderr -def test_cli_no_conversion_flag(monkeypatch): - monkeypatch.setattr(sys, "argv", ["yamlcli", "file.yaml"]) - with pytest.raises(SystemExit) as exc_info: - yamlcli.main() - assert exc_info.value.code == 1 +def test_cli_both_flags(): + result = runner.invoke(app, ["file.yaml", "--to-yaml", "--to-json"]) + assert result.exit_code == 1 + assert "Error" in result.stderr -def test_cli_both_flags(monkeypatch): - monkeypatch.setattr(sys, "argv", ["yamlcli", "file.yaml", "--to-json", "--to-yaml"]) - with pytest.raises(SystemExit) as exc_info: - yamlcli.main() - assert exc_info.value.code == 1 +def test_cli_custom_indent(sample_yaml_file): + result = runner.invoke(app, [sample_yaml_file, "--to-json", "--indent", "4"]) + assert result.exit_code == 0 -def test_cli_custom_indent(sample_yaml_file, capsys, monkeypatch): - monkeypatch.setattr( - sys, "argv", ["yamlcli", sample_yaml_file, "--to-json", "--indent", "4"] - ) - yamlcli.main() - output = capsys.readouterr().out - # Check if indentation is 4 spaces + output = result.stdout lines = output.strip().split("\n") + + # Check if indentation is 4 spaces assert any(line.startswith(" ") for line in lines) -def test_main_guard(sample_yaml_file, capsys, monkeypatch): - monkeypatch.setattr(sys, "argv", ["yamlcli", sample_yaml_file, "--to-json"]) +def test_main_guard(sample_yaml_file): + result = runner.invoke(app, [sample_yaml_file, "--to-json"]) - runpy.run_module("yamlcli", run_name="__main__") - output = capsys.readouterr().out - parsed_output = json.loads(output) - assert parsed_output["name"] == "John Doe" + assert result.exit_code == 0 + parsed = json.loads(result.stdout) + assert parsed["name"] == "John Doe" -def test_main_yaml_error(tmp_path, capsys, monkeypatch): - # Create an actual temporary YAML file + +def test_main_yaml_error(tmp_path, monkeypatch): tmp_file = tmp_path / "valid.yaml" tmp_file.write_text("key: value") - # Patch yaml.safe_load to raise YAMLError + # Force YAML error monkeypatch.setattr( "yaml.safe_load", lambda f: (_ for _ in ()).throw(yaml.YAMLError("mock YAML error")), ) - # Patch sys.argv to simulate CLI - monkeypatch.setattr(sys, "argv", ["yamlcli", str(tmp_file), "--to-json"]) - - with pytest.raises(SystemExit): - yamlcli.main() + result = runner.invoke(app, [str(tmp_file), "--to-json"]) - captured = capsys.readouterr() - assert "mock YAML error" in captured.err + # Typer(click) returns exit code 1 + assert result.exit_code != 0 + assert "mock YAML error" in result.stderr -def test_main_file_not_found(capsys, monkeypatch): - # Use a non-existent file path +def test_main_file_not_found(): fake_file = "/tmp/nonexistent.yaml" - monkeypatch.setattr(sys, "argv", ["yamlcli", fake_file, "--to-json"]) - - # Expect SystemExit - with pytest.raises(SystemExit) as exc_info: - yamlcli.main() + result = runner.invoke(app, [fake_file, "--to-json"]) - # Capture stderr - captured = capsys.readouterr() - assert f"Error: File not found - {fake_file}" in captured.err - assert exc_info.value.code == 1 + assert result.exit_code != 0 + assert f"Error: File not found - {fake_file}" in result.stderr