Spaces:
Runtime error
Runtime error
| from __future__ import annotations | |
| from pathlib import Path | |
| import shutil | |
| import pytest | |
| import kneiff.project_resources as project_resources_module | |
| from kneiff.config import KNEIFF_RC | |
| from kneiff.datasets.manifest.schema import manifest_schema_for_vocabulary | |
| from kneiff.datasets.export.workflow import ( | |
| build_dataset_sync_plan_from_config_path, | |
| validate_dataset_sync_plan_paths, | |
| ) | |
| from kneiff.infer.comfy.workflow_presets import ( | |
| project_workflow_files, | |
| resolve_showcase_workflow, | |
| ) | |
| from kneiff.project import ProjectContext, ProjectContextError, load_project_context | |
| from kneiff.project_layout import ( | |
| DEFAULT_ANIMA_CONFIG_FILENAME, | |
| DEFAULT_FLUX2_KLEIN_9B_CONFIG_FILENAME, | |
| PROJECT_PROMPTS_FILENAME, | |
| PROJECT_VOCABULARY_FILENAME, | |
| ) | |
| from kneiff.project_resources import ( | |
| load_project_prompt_catalog, | |
| load_project_vocabulary, | |
| validate_project_resources, | |
| validate_project_scaffold, | |
| ) | |
| from kneiff.project_scaffold import ( | |
| ensure_project_resources, | |
| initialize_project_scaffold, | |
| ) | |
| from kneiff.training.lora.config_templates import ( | |
| read_default_flux2_klein_9b_starter_config, | |
| ) | |
| AURORA_PROJECT_ROOT = Path(__file__).parent / "fixtures" / "projects" / "aurora" | |
| def _write_project_config(path: Path) -> None: | |
| """Write the smallest config-first dataset mapping used by safety tests.""" | |
| path.write_text( | |
| "mappings:\n fullbody:\n - 0-FULLBODY\n", | |
| encoding="utf-8", | |
| ) | |
| def test_project_context_derives_shallow_paths_without_loading_resources( | |
| tmp_path: Path, | |
| ) -> None: | |
| project = load_project_context(tmp_path, name="demo") | |
| assert project.name == "demo" | |
| assert project.root == tmp_path.resolve() | |
| assert project.vocabulary_path == tmp_path / PROJECT_VOCABULARY_FILENAME | |
| assert project.prompts_path == tmp_path / PROJECT_PROMPTS_FILENAME | |
| assert project.default_anima_config_path == ( | |
| tmp_path / "configs" / DEFAULT_ANIMA_CONFIG_FILENAME | |
| ) | |
| assert project.default_flux2_klein_9b_config_path == ( | |
| tmp_path / "configs" / DEFAULT_FLUX2_KLEIN_9B_CONFIG_FILENAME | |
| ) | |
| assert project.workflows_path == tmp_path / "workflows" | |
| def test_project_context_reports_missing_vocabulary_at_consuming_boundary( | |
| tmp_path: Path, | |
| ) -> None: | |
| project = load_project_context(tmp_path) | |
| with pytest.raises(ProjectContextError, match=PROJECT_VOCABULARY_FILENAME): | |
| load_project_vocabulary(project) | |
| def test_project_context_rejects_registered_root_symlink_before_resolving( | |
| tmp_path: Path, | |
| ) -> None: | |
| registered_target = tmp_path / "real-project" | |
| registered_target.mkdir() | |
| registered_root = tmp_path / "registered-project" | |
| registered_root.symlink_to(registered_target, target_is_directory=True) | |
| with pytest.raises(ProjectContextError, match="Project root must not be a symlink"): | |
| load_project_context(registered_root) | |
| def test_project_context_rejects_symlinked_storage_environment( | |
| tmp_path: Path, | |
| ) -> None: | |
| project_root = tmp_path / "project" | |
| project_root.mkdir() | |
| external_env = tmp_path / "external.env" | |
| external_env.write_text("KNF_WORKERS=9\n", encoding="utf-8") | |
| (project_root / KNEIFF_RC.spec.storage_env_filename).symlink_to(external_env) | |
| with pytest.raises( | |
| ProjectContextError, | |
| match="AppRC storage environment must not be a symlink", | |
| ): | |
| load_project_context(project_root) | |
| def test_project_context_rejects_storage_environment_directory( | |
| tmp_path: Path, | |
| ) -> None: | |
| project_root = tmp_path / "project" | |
| project_root.mkdir() | |
| (project_root / KNEIFF_RC.spec.storage_env_filename).mkdir() | |
| with pytest.raises( | |
| ProjectContextError, | |
| match="AppRC storage environment must be a file", | |
| ): | |
| load_project_context(project_root) | |
| def test_project_context_rejects_live_ancestor_symlink_redirection( | |
| tmp_path: Path, | |
| ) -> None: | |
| owner = tmp_path / "owner" | |
| project_root = owner / "project" | |
| project_root.mkdir(parents=True) | |
| project = load_project_context(project_root) | |
| original_owner = tmp_path / "original-owner" | |
| owner.rename(original_owner) | |
| redirected_owner = tmp_path / "redirected-owner" | |
| (redirected_owner / "project").mkdir(parents=True) | |
| owner.symlink_to(redirected_owner, target_is_directory=True) | |
| with pytest.raises( | |
| ProjectContextError, | |
| match="must remain at its canonical location after selection", | |
| ): | |
| project.optional_file(project.prompts_path, label="Project prompts") | |
| def test_ensure_project_resources_creates_root_level_files(tmp_path: Path) -> None: | |
| project = ensure_project_resources( | |
| tmp_path / "demo", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ) | |
| vocabulary = load_project_vocabulary(project) | |
| prompt_catalog = load_project_prompt_catalog(project) | |
| assert vocabulary.character_token == "Demo_Character" | |
| assert vocabulary.species_token == "demo_species" | |
| assert prompt_catalog.core_prompt_count > 0 | |
| assert prompt_catalog.overlay_prompt_count == 0 | |
| assert prompt_catalog.resolved_prompt_count == prompt_catalog.core_prompt_count | |
| assert prompt_catalog.prompts[0].id == "character_reference" | |
| assert project.prompts_path.read_text(encoding="utf-8") == ( | |
| "version: 1\nprompts: []\n" | |
| ) | |
| assert not (project.root / "kneiff.project.yaml").exists() | |
| assert not (project.root / "resources").exists() | |
| assert not project.workflows_path.exists() | |
| def test_ensure_project_resources_requires_tokens_only_for_missing_vocabulary( | |
| tmp_path: Path, | |
| ) -> None: | |
| root = tmp_path / "demo" | |
| root.mkdir() | |
| source = AURORA_PROJECT_ROOT / PROJECT_VOCABULARY_FILENAME | |
| (root / PROJECT_VOCABULARY_FILENAME).write_bytes(source.read_bytes()) | |
| project = ensure_project_resources(root) | |
| assert load_project_vocabulary(project).character_token == "Aurora_Fox" | |
| assert project.prompts_path.is_file() | |
| def test_project_resources_allow_missing_prompt_overlay(tmp_path: Path) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| project.prompts_path.unlink() | |
| validation = validate_project_scaffold(project) | |
| assert validation.project_prompt_count == 0 | |
| assert validation.resolved_prompt_count == validation.core_prompt_count | |
| def test_project_resource_validation_loads_vocabulary_once( | |
| monkeypatch: pytest.MonkeyPatch, | |
| tmp_path: Path, | |
| ) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| original_loader = project_resources_module.load_project_vocabulary | |
| load_count = 0 | |
| def tracked_loader(project_context: ProjectContext): | |
| nonlocal load_count | |
| load_count += 1 | |
| return original_loader(project_context) | |
| monkeypatch.setattr( | |
| project_resources_module, | |
| "load_project_vocabulary", | |
| tracked_loader, | |
| ) | |
| validation = project_resources_module.validate_project_resources(project) | |
| assert validation.vocabulary.character_token == "Demo_Character" | |
| assert load_count == 1 | |
| def test_project_init_keeps_existing_flux2_config_on_repeated_initialization( | |
| tmp_path: Path, | |
| ) -> None: | |
| project_root = tmp_path / "project" | |
| first = initialize_project_scaffold( | |
| project_root, | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ) | |
| config_path = first.project.default_flux2_klein_9b_config_path | |
| config_path.write_text( | |
| "# Existing user customization.\n" | |
| + read_default_flux2_klein_9b_starter_config(), | |
| encoding="utf-8", | |
| ) | |
| expected_bytes = config_path.read_bytes() | |
| second = initialize_project_scaffold(project_root) | |
| assert config_path.read_bytes() == expected_bytes | |
| assert config_path not in second.created_files | |
| def test_project_init_adopts_overlay_before_creating_vocabulary(tmp_path: Path) -> None: | |
| root = tmp_path / "project" | |
| root.mkdir() | |
| (root / PROJECT_PROMPTS_FILENAME).write_bytes( | |
| (AURORA_PROJECT_ROOT / PROJECT_PROMPTS_FILENAME).read_bytes() | |
| ) | |
| project = ensure_project_resources( | |
| root, | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ) | |
| prompt_catalog = load_project_prompt_catalog(project) | |
| assert prompt_catalog.overlay_prompt_count == 1 | |
| assert "aurora_portrait" in {prompt.id for prompt in prompt_catalog.prompts} | |
| def test_non_rook_project_resources_drive_selector_boundaries() -> None: | |
| project = load_project_context(AURORA_PROJECT_ROOT, name="aurora") | |
| vocabulary = load_project_vocabulary(project) | |
| schema = manifest_schema_for_vocabulary(vocabulary) | |
| prompt_catalog = load_project_prompt_catalog(project) | |
| workflow = resolve_showcase_workflow("demo", project=project) | |
| assert vocabulary.character_token == "Aurora_Fox" | |
| assert vocabulary.species_token == "foxkin" | |
| assert schema.manual_columns == ( | |
| "subject", | |
| "appearance", | |
| "composition", | |
| "pose_and_behavior", | |
| "face", | |
| "anatomy", | |
| "sexual_content", | |
| "scene", | |
| "presentation", | |
| "fallback", | |
| ) | |
| aurora_prompt = next( | |
| prompt for prompt in prompt_catalog.prompts if prompt.id == "aurora_portrait" | |
| ) | |
| assert aurora_prompt.positive_prompt("nlg").startswith("Aurora_Fox.") | |
| assert list(project_workflow_files(project)) == ["demo"] | |
| assert ( | |
| workflow.source.path | |
| == (AURORA_PROJECT_ROOT / "workflows" / "showcase_demo.workflow.json").resolve() | |
| ) | |
| def test_project_workflow_discovery_rejects_symlinked_directory( | |
| tmp_path: Path, | |
| ) -> None: | |
| project_root = tmp_path / "project" | |
| project_root.mkdir() | |
| external_workflows = tmp_path / "external-workflows" | |
| external_workflows.mkdir() | |
| (project_root / "workflows").symlink_to( | |
| external_workflows, | |
| target_is_directory=True, | |
| ) | |
| project = load_project_context(project_root) | |
| with pytest.raises(ValueError, match="directory must not be a symlink"): | |
| project_workflow_files(project) | |
| def test_project_workflow_discovery_rejects_symlinked_file(tmp_path: Path) -> None: | |
| project_root = tmp_path / "project" | |
| workflows_path = project_root / "workflows" | |
| workflows_path.mkdir(parents=True) | |
| external_workflow = tmp_path / "external.workflow.json" | |
| external_workflow.write_text("{}", encoding="utf-8") | |
| (workflows_path / "showcase_demo.workflow.json").symlink_to(external_workflow) | |
| project = load_project_context(project_root) | |
| with pytest.raises(ValueError, match="workflow path must not be a symlink"): | |
| project_workflow_files(project) | |
| def test_project_workflow_discovery_rejects_normalized_duplicate_presets( | |
| tmp_path: Path, | |
| ) -> None: | |
| project_root = tmp_path / "project" | |
| workflows_path = project_root / "workflows" | |
| workflows_path.mkdir(parents=True) | |
| (workflows_path / "showcase_NOOB_Nova.workflow.json").write_text( | |
| "{}", | |
| encoding="utf-8", | |
| ) | |
| (workflows_path / "showcase_noob-nova.workflow.json").write_text( | |
| "{}", | |
| encoding="utf-8", | |
| ) | |
| project = load_project_context(project_root) | |
| with pytest.raises(ValueError, match="duplicate preset id 'noob-nova'"): | |
| project_workflow_files(project) | |
| def test_project_resource_validation_loads_showcase_workflow_contract( | |
| tmp_path: Path, | |
| ) -> None: | |
| project = ensure_project_resources( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ) | |
| project.workflows_path.mkdir() | |
| (project.workflows_path / "showcase_incomplete.workflow.json").write_text( | |
| """ | |
| { | |
| "1": { | |
| "class_type": "CLIPTextEncode", | |
| "inputs": {"text": "__KNF_PROMPT__"} | |
| } | |
| } | |
| """, | |
| encoding="utf-8", | |
| ) | |
| with pytest.raises(ValueError, match="missing required placeholders"): | |
| validate_project_resources(project) | |
| def test_project_scaffold_validation_rejects_required_directory_mutations( | |
| tmp_path: Path, | |
| dirname: str, | |
| mutation: str, | |
| error: str, | |
| ) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| path = project.root / dirname | |
| shutil.rmtree(path) | |
| if mutation == "wrong_type": | |
| path.write_text("wrong type\n", encoding="utf-8") | |
| elif mutation == "symlink": | |
| external = tmp_path / f"external-{dirname}" | |
| external.mkdir() | |
| path.symlink_to(external, target_is_directory=True) | |
| with pytest.raises(ProjectContextError, match=error): | |
| validate_project_scaffold(project) | |
| def test_dataset_plan_rejects_fixed_path_symlink_added_after_project_init( | |
| tmp_path: Path, | |
| mutated_path: str, | |
| ) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| config_path = project.configs_path / "demo.knf.yaml" | |
| _write_project_config(config_path) | |
| plan = build_dataset_sync_plan_from_config_path(config_path) | |
| path = project.root / mutated_path | |
| external = tmp_path / f"external-{path.name}" | |
| if path.exists() and path.is_dir(): | |
| path.rename(external) | |
| path.symlink_to(external, target_is_directory=True) | |
| elif path.suffix: | |
| external.write_text("external", encoding="utf-8") | |
| path.symlink_to(external) | |
| else: | |
| external.mkdir() | |
| path.symlink_to(external, target_is_directory=True) | |
| with pytest.raises(ProjectContextError, match="must not be a symlink"): | |
| validate_dataset_sync_plan_paths(plan) | |
| def test_dataset_plan_rejects_fixed_path_wrong_type_after_project_init( | |
| tmp_path: Path, | |
| mutated_path: str, | |
| ) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| config_path = project.configs_path / "demo.knf.yaml" | |
| _write_project_config(config_path) | |
| plan = build_dataset_sync_plan_from_config_path(config_path) | |
| path = project.root / mutated_path | |
| if path.is_dir(): | |
| shutil.rmtree(path) | |
| path.write_text("wrong type", encoding="utf-8") | |
| elif path.suffix: | |
| path.mkdir() | |
| else: | |
| path.write_text("wrong type", encoding="utf-8") | |
| with pytest.raises(ProjectContextError, match="must be"): | |
| validate_dataset_sync_plan_paths(plan) | |
| def test_project_source_boundary_allows_nested_source_symlinks(tmp_path: Path) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| external_image = tmp_path / "external.png" | |
| external_image.write_bytes(b"image") | |
| nested_dir = project.source_path / "nested" | |
| nested_dir.mkdir() | |
| (nested_dir / "linked.png").symlink_to(external_image) | |
| assert ( | |
| project.require_directory( | |
| project.source_path, | |
| label="Project SOURCE directory", | |
| ) | |
| == project.source_path | |
| ) | |
| def test_project_prompt_catalog_rejects_post_init_symlink(tmp_path: Path) -> None: | |
| project = initialize_project_scaffold( | |
| tmp_path / "project", | |
| activation_token="Demo_Character", | |
| species_token="demo_species", | |
| ).project | |
| external_prompts = tmp_path / "external-prompts.yaml" | |
| project.prompts_path.replace(external_prompts) | |
| project.prompts_path.symlink_to(external_prompts) | |
| with pytest.raises(ValueError, match="must not be a symlink"): | |
| load_project_prompt_catalog(project) | |