Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[run]
branch = True
source = struct_module

[report]
omit =
tests/*
*/__init__.py
show_missing = True
skip_covered = True
2 changes: 1 addition & 1 deletion .github/workflows/test-script.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ jobs:
shell: bash
run: |
echo "REPORT_FILE=${REPORT_OUTPUT}" >> "$GITHUB_ENV"
pytest --cov --cov-branch --cov-report=xml -v --md-report --md-report-flavor gfm --md-report-exclude-outcomes passed skipped xpassed --md-report-output "$REPORT_OUTPUT" --pyargs tests
pytest --cov=struct_module --cov-branch --cov-report=xml -v --md-report --md-report-flavor gfm --md-report-exclude-outcomes passed skipped xpassed --md-report-output "$REPORT_OUTPUT" --pyargs tests

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ build/*

# MkDocs generated documentation
site/docs/

# Coverage artifacts
.coverage
coverage.xml
htmlcov/
50 changes: 25 additions & 25 deletions struct_module/content_fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,41 +84,41 @@ def _fetch_http_url(self, url):

return response.text

def _fetch_github_file(self, github_url):
def _fetch_github_file(self, github_path):
"""
Fetch a file from a GitHub repository using HTTPS.
Expected format: github://owner/repo/branch/file_path
Dispatcher passes: owner/repo/branch/file_path
"""
self.logger.debug(f"Fetching content from GitHub: {github_url}")
match = re.match(r"github://([^/]+)/([^/]+)/([^/]+)/(.+)", github_url)
self.logger.debug(f"Fetching content from GitHub: {github_path}")
match = re.match(r"([^/]+)/([^/]+)/([^/]+)/(.+)", github_path)
if not match:
raise ValueError("Invalid GitHub URL format. Expected github://owner/repo/branch/file_path")
raise ValueError("Invalid GitHub path. Expected owner/repo/branch/file_path")

owner, repo, branch, file_path = match.groups()
return self._clone_or_fetch_github(owner, repo, branch, file_path, https=True)

def _fetch_github_https_file(self, github_url):
def _fetch_github_https_file(self, github_path):
"""
Fetch a file from a GitHub repository using HTTPS.
Expected format: githubhttps://owner/repo/branch/file_path
Dispatcher passes: owner/repo/branch/file_path
"""
self.logger.debug(f"Fetching content from GitHub (HTTPS): {github_url}")
match = re.match(r"githubhttps://([^/]+)/([^/]+)/([^/]+)/(.+)", github_url)
self.logger.debug(f"Fetching content from GitHub (HTTPS): {github_path}")
match = re.match(r"([^/]+)/([^/]+)/([^/]+)/(.+)", github_path)
if not match:
raise ValueError("Invalid GitHub URL format. Expected githubhttps://owner/repo/branch/file_path")
raise ValueError("Invalid GitHub path. Expected owner/repo/branch/file_path")

owner, repo, branch, file_path = match.groups()
return self._clone_or_fetch_github(owner, repo, branch, file_path, https=True)

def _fetch_github_ssh_file(self, github_url):
def _fetch_github_ssh_file(self, github_path):
"""
Fetch a file from a GitHub repository using SSH.
Expected format: githubssh://owner/repo/branch/file_path
Dispatcher passes: owner/repo/branch/file_path
"""
self.logger.debug(f"Fetching content from GitHub (SSH): {github_url}")
match = re.match(r"githubssh://([^/]+)/([^/]+)/([^/]+)/(.+)", github_url)
self.logger.debug(f"Fetching content from GitHub (SSH): {github_path}")
match = re.match(r"([^/]+)/([^/]+)/([^/]+)/(.+)", github_path)
if not match:
raise ValueError("Invalid GitHub URL format. Expected githubssh://owner/repo/branch/file_path")
raise ValueError("Invalid GitHub path. Expected owner/repo/branch/file_path")

owner, repo, branch, file_path = match.groups()
return self._clone_or_fetch_github(owner, repo, branch, file_path, https=False)
Expand All @@ -143,18 +143,18 @@ def _clone_or_fetch_github(self, owner, repo, branch, file_path, https=True):
with file_full_path.open('r') as file:
return file.read()

def _fetch_s3_file(self, s3_url):
def _fetch_s3_file(self, s3_path):
"""
Fetch a file from an S3 bucket.
Expected format: s3://bucket_name/key
Dispatcher passes: bucket_name/key
"""
if not boto3_available:
raise ImportError("boto3 is not installed. Please install it to use S3 fetching.")

self.logger.debug(f"Fetching content from S3: {s3_url}")
match = re.match(r"s3://([^/]+)/(.+)", s3_url)
self.logger.debug(f"Fetching content from S3: {s3_path}")
match = re.match(r"([^/]+)/(.+)", s3_path)
if not match:
raise ValueError("Invalid S3 URL format. Expected s3://bucket_name/key")
raise ValueError("Invalid S3 path. Expected bucket_name/key")

bucket_name, key = match.groups()
local_file_path = self.cache_dir / Path(key).name
Expand All @@ -176,18 +176,18 @@ def _fetch_s3_file(self, s3_url):
with local_file_path.open('r') as file:
return file.read()

def _fetch_gcs_file(self, gcs_url):
def _fetch_gcs_file(self, gcs_path):
"""
Fetch a file from Google Cloud Storage.
Expected format: gs://bucket_name/key
Dispatcher passes: bucket_name/key
"""
if not gcs_available:
raise ImportError("google-cloud-storage is not installed. Please install it to use GCS fetching.")

self.logger.debug(f"Fetching content from GCS: {gcs_url}")
match = re.match(r"gs://([^/]+)/(.+)", gcs_url)
self.logger.debug(f"Fetching content from GCS: {gcs_path}")
match = re.match(r"([^/]+)/(.+)", gcs_path)
if not match:
raise ValueError("Invalid GCS URL format. Expected gs://bucket_name/key")
raise ValueError("Invalid GCS path. Expected bucket_name/key")

bucket_name, key = match.groups()
local_file_path = self.cache_dir / Path(key).name
Expand Down
181 changes: 181 additions & 0 deletions tests/test_commands_more.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import argparse
import subprocess
from unittest.mock import patch, MagicMock

import pytest

from struct_module.commands.generate import GenerateCommand
from struct_module.commands.info import InfoCommand
from struct_module.commands.list import ListCommand
from struct_module.commands.mcp import MCPCommand
from struct_module.commands.validate import ValidateCommand


@pytest.fixture
def parser():
return argparse.ArgumentParser()


def test_generate_creates_base_path_and_console_output(parser, tmp_path):
command = GenerateCommand(parser)
args = parser.parse_args(['struct-x', str(tmp_path / 'base')])

# Minimal config: one file item with string content to avoid fetch
config = {'files': [{'hello.txt': 'Hello'}], 'folders': []}

# Ensure the input store file exists to avoid FileNotFoundError inside TemplateRenderer
store_dir = tmp_path / 'store'
store_dir.mkdir(parents=True, exist_ok=True)
with open(store_dir / 'input.json', 'w') as fh:
fh.write('{}')

with patch.object(command, '_load_yaml_config', return_value=config), \
patch('os.path.exists', side_effect=lambda p: False if str(tmp_path / 'base') in p else True), \
patch('os.makedirs') as mock_makedirs, \
patch('builtins.print') as mock_print:
# Choose console output to avoid writing files
args.output = 'file' # still triggers base path creation logic
args.input_store = str(store_dir / 'input.json')
args.dry_run = True
args.vars = None
args.backup = None
args.file_strategy = 'overwrite'
args.global_system_prompt = None
args.structures_path = None
args.non_interactive = True

command.execute(args)

mock_makedirs.assert_called() # base path created
mock_makedirs.assert_called() # base path created


def test_generate_pre_hook_failure_aborts(parser, tmp_path):
command = GenerateCommand(parser)
args = parser.parse_args(['struct-x', str(tmp_path)])

config = {'pre_hooks': ['exit 1'], 'files': []}

def fake_run(cmd, shell, check, capture_output, text):
raise subprocess.CalledProcessError(1, cmd, output='', stderr='boom')

with patch.object(command, '_load_yaml_config', return_value=config), \
patch('subprocess.run', side_effect=fake_run), \
patch.object(command, '_create_structure') as mock_create_structure:
command.execute(args)
mock_create_structure.assert_not_called()


def test_generate_mappings_file_not_found(parser, tmp_path):
command = GenerateCommand(parser)
args = parser.parse_args(['struct-x', str(tmp_path)])
args.mappings_file = ['missing.yaml']

with patch('os.path.exists', return_value=False):
# Should return early without error
command.execute(args)


def test_info_nonexistent_file_logs_error(parser):
command = InfoCommand(parser)
args = parser.parse_args(['does-not-exist'])

with patch('os.path.exists', return_value=False):
# Should just log error and return without exception
command.execute(args)


def test_list_with_custom_structures_path(parser, tmp_path):
command = ListCommand(parser)
args = parser.parse_args(['-s', str(tmp_path / 'custom')])

custom = str(tmp_path / 'custom')
contribs = '/path/to/contribs'

def mock_join(*parts):
# emulate join used in list._list_structures
if parts[-1] == '..':
return '/path/to' # dir of commands
if parts[-1] == 'contribs':
return contribs
return '/'.join(parts)

walk_map = {
custom: [(custom, [], ['a.yaml'])],
contribs: [(contribs, [], ['b.yaml'])],
}

def mock_walk(path):
return walk_map.get(path, [])

with patch('os.path.dirname', return_value='/path/to/commands'), \
patch('os.path.realpath', return_value='/path/to/commands'), \
patch('os.path.join', side_effect=mock_join), \
patch('os.walk', side_effect=mock_walk), \
patch('builtins.print') as mock_print:
command._list_structures(args)
mock_print.assert_called() # printed list


def test_mcp_command_server_flag(parser):
command = MCPCommand(parser)
args = parser.parse_args(['--server'])

async def fake_start():
return None

with patch.object(command, '_start_mcp_server', side_effect=fake_start) as mock_start:
command.execute(args)
mock_start.assert_called_once()


# ValidateCommand error-path tests on helpers

def test_validate_structure_config_errors(parser):
v = ValidateCommand(parser)
with pytest.raises(ValueError):
v._validate_structure_config('not-a-list')
with pytest.raises(ValueError):
v._validate_structure_config(["not-a-dict"]) # non-dict item
with pytest.raises(ValueError):
v._validate_structure_config([{123: 'abc'}]) # non-str name
with pytest.raises(ValueError):
v._validate_structure_config([{ 'x': 123 }]) # non-str/non-dict content
with pytest.raises(ValueError):
v._validate_structure_config([{ 'x': {} }]) # dict missing keys


def test_validate_folders_config_errors(parser):
v = ValidateCommand(parser)
with pytest.raises(ValueError):
v._validate_folders_config('not-a-list')
with pytest.raises(ValueError):
v._validate_folders_config(["not-a-dict"]) # non-dict item
with pytest.raises(ValueError):
v._validate_folders_config([{123: {}}]) # non-str name
with pytest.raises(ValueError):
v._validate_folders_config([{ 'name': 'not-a-dict' }])
with pytest.raises(ValueError):
v._validate_folders_config([{ 'name': {} }]) # missing 'struct'
with pytest.raises(ValueError):
v._validate_folders_config([{ 'name': { 'struct': 10 } }]) # invalid type
with pytest.raises(ValueError):
v._validate_folders_config([{ 'name': { 'struct': 'x', 'with': 'not-dict' } }])


def test_validate_variables_config_errors(parser):
v = ValidateCommand(parser)
with pytest.raises(ValueError):
v._validate_variables_config('not-a-list')
with pytest.raises(ValueError):
v._validate_variables_config(["not-a-dict"]) # non-dict item
with pytest.raises(ValueError):
v._validate_variables_config([{123: {}}]) # non-str name
with pytest.raises(ValueError):
v._validate_variables_config([{ 'name': 'not-a-dict' }])
with pytest.raises(ValueError):
v._validate_variables_config([{ 'name': {} }]) # missing type
with pytest.raises(ValueError):
v._validate_variables_config([{ 'name': { 'type': 'bad' } }])
with pytest.raises(ValueError):
v._validate_variables_config([{ 'name': { 'type': 'boolean', 'default': 'yes' } }])
Loading
Loading