Skip to content

Commit f2690e6

Browse files
feat(cli): Add normalize command using ManifestNormalizer code path
- Add normalize command to manifest CLI command group - Uses ManifestNormalizer to deduplicate definitions and create references - Follows same patterns as validate/migrate commands (file handling, error handling, help text) - Add comprehensive unit tests covering all scenarios (dry-run, file not found, invalid YAML, etc.) - Update help tests to include normalize command - All 22 unit tests pass successfully Co-Authored-By: AJ Steers <[email protected]>
1 parent 37f391b commit f2690e6

File tree

2 files changed

+205
-0
lines changed

2 files changed

+205
-0
lines changed

airbyte_cdk/cli/airbyte_cdk/_manifest.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from airbyte_cdk.sources.declarative.manifest_declarative_source import (
2121
_get_declarative_component_schema,
2222
)
23+
from airbyte_cdk.sources.declarative.parsers.manifest_normalizer import (
24+
ManifestNormalizer,
25+
)
2326

2427
EXIT_SUCCESS = 0
2528
EXIT_FIXABLE_VIA_MIGRATION = 1
@@ -218,6 +221,76 @@ def migrate_manifest(manifest_path: Path, dry_run: bool) -> None:
218221
sys.exit(EXIT_FIXABLE_VIA_MIGRATION)
219222

220223

224+
@manifest_cli_group.command("normalize")
225+
@click.option(
226+
"--manifest-path",
227+
type=click.Path(exists=True, path_type=Path),
228+
default="manifest.yaml",
229+
help="Path to the manifest file to normalize (default: manifest.yaml)",
230+
)
231+
@click.option(
232+
"--dry-run",
233+
is_flag=True,
234+
help="Show what changes would be made without actually modifying the file",
235+
)
236+
def normalize_manifest(manifest_path: Path, dry_run: bool) -> None:
237+
"""Normalize a manifest file by removing duplicated definitions and replacing them with references.
238+
239+
This command normalizes the manifest file by deduplicating elements and
240+
creating references to shared components, making the manifest more maintainable.
241+
"""
242+
try:
243+
original_manifest = yaml.safe_load(manifest_path.read_text())
244+
245+
if not isinstance(original_manifest, dict):
246+
click.echo(
247+
f"❌ Error: Manifest file {manifest_path} does not contain a valid YAML dictionary",
248+
err=True,
249+
)
250+
sys.exit(EXIT_GENERAL_ERROR)
251+
252+
schema = _get_declarative_component_schema()
253+
normalizer = ManifestNormalizer(original_manifest, schema)
254+
normalized_manifest = normalizer.normalize()
255+
256+
if normalized_manifest == original_manifest:
257+
click.echo(f"✅ Manifest {manifest_path} is already normalized - no changes needed.")
258+
return
259+
260+
if dry_run:
261+
click.echo(f"🔍 Dry run - changes that would be made to {manifest_path}:")
262+
click.echo(" Duplicated definitions would be removed and replaced with references.")
263+
click.echo(" Run without --dry-run to apply the changes.")
264+
return
265+
266+
manifest_path.write_text(
267+
yaml.dump(normalized_manifest, default_flow_style=False, sort_keys=False)
268+
)
269+
270+
click.echo(f"✅ Successfully normalized {manifest_path}.")
271+
272+
try:
273+
validate(normalized_manifest, schema)
274+
click.echo(f"✅ Normalized manifest {manifest_path} passes validation.")
275+
except ValidationError as e:
276+
click.echo(
277+
f"⚠️ Warning: Normalized manifest {manifest_path} has validation issues:",
278+
err=True,
279+
)
280+
click.echo(f" {e.message}", err=True)
281+
click.echo(" Manual fixes may be required.", err=True)
282+
283+
except FileNotFoundError:
284+
click.echo(f"❌ Error: Manifest file {manifest_path} not found", err=True)
285+
sys.exit(EXIT_GENERAL_ERROR)
286+
except yaml.YAMLError as e:
287+
click.echo(f"❌ Error: Invalid YAML in {manifest_path}: {e}", err=True)
288+
sys.exit(EXIT_GENERAL_ERROR)
289+
except Exception as e:
290+
click.echo(f"❌ Unexpected error normalizing {manifest_path}: {e}", err=True)
291+
sys.exit(EXIT_GENERAL_ERROR)
292+
293+
221294
__all__ = [
222295
"manifest_cli_group",
223296
]

unit_tests/cli/test_manifest_cli.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from click.testing import CliRunner
1010

1111
from airbyte_cdk.cli.airbyte_cdk._manifest import manifest_cli_group
12+
from airbyte_cdk.sources.declarative.parsers.manifest_normalizer import ManifestNormalizer
1213

1314

1415
class TestManifestValidateCommand:
@@ -373,6 +374,126 @@ def test_migrate_manifest_not_dict(self, tmp_path: Path) -> None:
373374
assert "does not contain a valid YAML dictionary" in result.output
374375

375376

377+
class TestManifestNormalizeCommand:
378+
"""Test cases for the manifest normalize command."""
379+
380+
def test_normalize_valid_manifest_no_changes(self, tmp_path: Path) -> None:
381+
"""Test normalize command with a manifest that doesn't need normalization."""
382+
manifest_content = {
383+
"version": "0.29.0",
384+
"type": "DeclarativeSource",
385+
"check": {"type": "CheckStream", "stream_names": ["users"]},
386+
"streams": [
387+
{
388+
"type": "DeclarativeStream",
389+
"name": "users",
390+
"primary_key": [],
391+
"retriever": {
392+
"type": "SimpleRetriever",
393+
"requester": {
394+
"type": "HttpRequester",
395+
"url_base": "https://api.example.com",
396+
"path": "/users",
397+
},
398+
"record_selector": {
399+
"type": "RecordSelector",
400+
"extractor": {"type": "DpathExtractor", "field_path": []},
401+
},
402+
},
403+
}
404+
],
405+
}
406+
407+
manifest_file = tmp_path / "manifest.yaml"
408+
with open(manifest_file, "w") as f:
409+
yaml.dump(manifest_content, f)
410+
411+
runner = CliRunner()
412+
result = runner.invoke(
413+
manifest_cli_group, ["normalize", "--manifest-path", str(manifest_file)]
414+
)
415+
416+
assert result.exit_code == 0
417+
assert "✅ Manifest" in result.output
418+
assert "is already normalized" in result.output
419+
420+
def test_normalize_manifest_dry_run(self, tmp_path: Path) -> None:
421+
"""Test normalize command with dry-run flag."""
422+
manifest_content = {
423+
"version": "0.29.0",
424+
"type": "DeclarativeSource",
425+
"check": {"type": "CheckStream", "stream_names": ["users"]},
426+
"streams": [
427+
{
428+
"type": "DeclarativeStream",
429+
"name": "users",
430+
"primary_key": [],
431+
"retriever": {
432+
"type": "SimpleRetriever",
433+
"requester": {
434+
"type": "HttpRequester",
435+
"url_base": "https://api.example.com",
436+
"path": "/users",
437+
},
438+
"record_selector": {
439+
"type": "RecordSelector",
440+
"extractor": {"type": "DpathExtractor", "field_path": []},
441+
},
442+
},
443+
}
444+
],
445+
}
446+
447+
manifest_file = tmp_path / "manifest.yaml"
448+
with open(manifest_file, "w") as f:
449+
yaml.dump(manifest_content, f)
450+
451+
runner = CliRunner()
452+
result = runner.invoke(
453+
manifest_cli_group, ["normalize", "--manifest-path", str(manifest_file), "--dry-run"]
454+
)
455+
456+
assert result.exit_code == 0
457+
assert ("🔍 Dry run" in result.output) or ("is already normalized" in result.output)
458+
459+
def test_normalize_manifest_file_not_found(self) -> None:
460+
"""Test normalize command with non-existent manifest file."""
461+
runner = CliRunner()
462+
result = runner.invoke(
463+
manifest_cli_group, ["normalize", "--manifest-path", "nonexistent.yaml"]
464+
)
465+
466+
assert result.exit_code == 2
467+
assert "--manifest-path" in result.output
468+
assert "does not exist" in result.output
469+
470+
def test_normalize_invalid_yaml(self, tmp_path: Path) -> None:
471+
"""Test normalize command with invalid YAML file."""
472+
manifest_file = tmp_path / "invalid.yaml"
473+
manifest_file.write_text("invalid: yaml: content: [")
474+
475+
runner = CliRunner()
476+
result = runner.invoke(
477+
manifest_cli_group, ["normalize", "--manifest-path", str(manifest_file)]
478+
)
479+
480+
assert result.exit_code == 3
481+
assert "❌ Error: Invalid YAML" in result.output
482+
483+
def test_normalize_non_dict_yaml(self, tmp_path: Path) -> None:
484+
"""Test normalize command with YAML that's not a dictionary."""
485+
manifest_file = tmp_path / "list.yaml"
486+
manifest_file.write_text("- item1\n- item2")
487+
488+
runner = CliRunner()
489+
result = runner.invoke(
490+
manifest_cli_group, ["normalize", "--manifest-path", str(manifest_file)]
491+
)
492+
493+
assert result.exit_code == 3
494+
assert "does not contain a valid YAML dictionary" in result.output
495+
496+
376497
class TestManifestCliHelp:
377498
"""Test cases for CLI help text and command structure."""
378499

@@ -385,6 +506,7 @@ def test_manifest_group_help(self) -> None:
385506
assert "Manifest related commands" in result.output
386507
assert "validate" in result.output
387508
assert "migrate" in result.output
509+
assert "normalize" in result.output
388510

389511
def test_validate_command_help(self) -> None:
390512
"""Test that the validate command shows help with exit codes."""
@@ -407,3 +529,13 @@ def test_migrate_command_help(self) -> None:
407529
assert "Apply migrations" in result.output
408530
assert "--dry-run" in result.output
409531
assert "--manifest-path" in result.output
532+
533+
def test_normalize_command_help(self) -> None:
534+
"""Test that normalize command help text is displayed correctly."""
535+
runner = CliRunner()
536+
result = runner.invoke(manifest_cli_group, ["normalize", "--help"])
537+
538+
assert result.exit_code == 0
539+
assert "Normalize a manifest file by removing duplicated definitions" in result.output
540+
assert "--manifest-path" in result.output
541+
assert "--dry-run" in result.output

0 commit comments

Comments
 (0)