Skip to content

Commit 1aab2db

Browse files
committedMar 14, 2025·
add: partial copy method
1 parent 9a39e1f commit 1aab2db

File tree

8 files changed

+292
-8
lines changed

8 files changed

+292
-8
lines changed
 

‎foliant/__init__.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = '1.0.13'
1+
__version__ = '1.0.14'

‎foliant/backends/base.py

+101-4
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
import re
2+
import os
13
from importlib import import_module
2-
from shutil import copytree
4+
from shutil import copytree, copy
5+
from pathlib import Path
36
from datetime import date
47
from logging import Logger
5-
8+
from glob import glob
69
from foliant.utils import spinner
7-
10+
from typing import Union, List
811

912
class BaseBackend():
1013
'''Base backend. All backends must inherit from this one.'''
@@ -82,6 +85,97 @@ def apply_preprocessor(self, preprocessor: str or dict):
8285
f'Failed to apply preprocessor {preprocessor_name}: {exception}'
8386
) from exception
8487

88+
@staticmethod
89+
def partial_copy(
90+
source: Union[str, Path, List[Union[str, Path]]],
91+
destination: Union[str, Path],
92+
root: Union[str, Path]
93+
) -> None:
94+
"""
95+
Copies files, a list of files, or files matching a glob pattern to the specified folder.
96+
Creates all necessary directories if they don't exist.
97+
98+
:param source: A file path, a list of file paths, or a glob pattern (as a string or Path object).
99+
:param destination: Target folder (as a string or Path object).
100+
:param root: Base folder to calculate relative paths (optional). If not provided, the parent directory of the source is used.
101+
"""
102+
# Convert destination to a Path object
103+
destination_path = Path(destination)
104+
105+
def extract_first_header(file_path):
106+
"""Extracts the first first-level header from the Markdown file."""
107+
with open(file_path, 'r', encoding='utf-8') as file:
108+
for line in file:
109+
match = re.match(r'^#\s+(.*)', line)
110+
if match:
111+
return match.group(0) # Returns the header
112+
return None # If the header is not found
113+
114+
def copy_files_without_content(src_dir, dst_dir):
115+
"""Copies files, leaving only the first-level header."""
116+
if not os.path.exists(dst_dir):
117+
os.makedirs(dst_dir)
118+
119+
for file_root, _, files in os.walk(src_dir):
120+
for file_name in files:
121+
src_file_path = os.path.join(file_root, file_name)
122+
dirs = os.path.relpath(file_root, src_dir)
123+
dst_file_path = Path(os.path.join(dst_dir, dirs, file_name))
124+
dst_file_path.parent.mkdir(parents=True, exist_ok=True)
125+
if file_name.endswith('.md'):
126+
header = extract_first_header(src_file_path)
127+
if header:
128+
with open(dst_file_path, 'w', encoding='utf-8') as dst_file:
129+
dst_file.write(header + '\n')
130+
else:
131+
copy(src_file_path, dst_file_path)
132+
133+
copy_files_without_content(root, destination)
134+
# Handle case where source is a list of files
135+
if isinstance(source, str) and ',' in source:
136+
print( source)
137+
source = source.split(',')
138+
if isinstance(source, list):
139+
files_to_copy = []
140+
for item in source:
141+
item_path = Path(item)
142+
if not item_path.exists():
143+
raise FileNotFoundError(f"Source '{item}' not found.")
144+
files_to_copy.append(item_path)
145+
else:
146+
# Convert source to a Path object if it's a string
147+
if isinstance(source, str):
148+
source_path = Path(source)
149+
else:
150+
source_path = source
151+
152+
# Check if the source is a glob pattern
153+
if isinstance(source, str) and ('*' in source or '?' in source or '[' in source):
154+
# Use glob to find files matching the pattern
155+
files_to_copy = [Path(file) for file in glob(source, recursive=True)]
156+
else:
157+
# Check if the source file or directory exists
158+
if not source_path.exists():
159+
raise FileNotFoundError(f"Source '{source_path}' not found.")
160+
files_to_copy = [source_path]
161+
162+
# Determine the root directory for calculating relative paths
163+
root = Path(root)
164+
165+
# Copy each file
166+
for file_path in files_to_copy:
167+
# Calculate the relative path
168+
relative_path = file_path.relative_to(root)
169+
170+
# Full path to the destination file
171+
destination_file_path = destination_path / relative_path
172+
173+
# Create directories if they don't exist
174+
destination_file_path.parent.mkdir(parents=True, exist_ok=True)
175+
176+
# Copy the file
177+
copy(file_path, destination_file_path)
178+
85179
def preprocess_and_make(self, target: str) -> str:
86180
'''Apply preprocessors required by the selected backend and defined in the config file,
87181
then run the ``make`` method.
@@ -93,7 +187,10 @@ def preprocess_and_make(self, target: str) -> str:
93187

94188
src_path = self.project_path / self.config['src_dir']
95189

96-
copytree(src_path, self.working_dir)
190+
if self.context['only_partial']:
191+
self.partial_copy(self.context['only_partial'], self.working_dir, src_path)
192+
else:
193+
copytree(src_path, self.working_dir)
97194

98195
common_preprocessors = (
99196
*self.required_preprocessors_before,

‎foliant/backends/pre.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ def __init__(self, *args, **kwargs):
2323

2424
def make(self, target: str) -> str:
2525
rmtree(self._preprocessed_dir_name, ignore_errors=True)
26-
copytree(self.working_dir, self._preprocessed_dir_name)
26+
if self.context['only_partial']:
27+
self.partial_copy(self.working_dir / self.context['only_partial'], self._preprocessed_dir_name)
28+
else:
29+
copytree(self.working_dir, self._preprocessed_dir_name)
2730

2831
return self._preprocessed_dir_name

‎foliant/cli/make.py

+5-1
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ def get_config(
140140
'logs_dir': 'Path to the directory to store logs, defaults to project path.',
141141
'quiet': 'Hide all output accept for the result. Useful for piping.',
142142
'keep_tmp': 'Keep the tmp directory after the build.',
143+
'only_partial': 'using only a partial list of files',
143144
'debug': 'Log all events during build. If not set, only warnings and errors are logged.'
144145
}
145146
)
@@ -152,6 +153,7 @@ def make(
152153
logs_dir='',
153154
quiet=False,
154155
keep_tmp=False,
156+
only_partial='',
155157
debug=False
156158
):
157159
'''Make TARGET with BACKEND.'''
@@ -189,7 +191,9 @@ def make(
189191
'project_path': project_path,
190192
'config': config,
191193
'target': target,
192-
'backend': backend
194+
'backend': backend,
195+
'keep_tmp': keep_tmp,
196+
'only_partial': only_partial
193197
}
194198

195199
backend_module = import_module(f'foliant.backends.{backend}')

‎pyproject.toml

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "foliant"
3-
version = "1.0.13"
3+
version = "1.0.14"
44
description = "Modular, Markdown-based documentation generator that makes pdf, docx, html, and more."
55
license = "MIT"
66
authors = ["Konstantin Molchanov <moigagoo@live.com>"]

‎test.sh

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
#!/bin/bash
2+
3+
# before testing make sure that you have installed the fresh version of preprocessor:
4+
poetry install --no-interaction
5+
6+
# run tests
7+
poetry run pytest --cov=foliant -v

‎test_in_docker.sh

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
#!/bin/bash
2+
3+
# Write Dockerfile
4+
echo "FROM python:3.9.21-alpine3.20" > Dockerfile
5+
echo "RUN apk add --no-cache --upgrade bash && \
6+
pip install poetry==1 && \
7+
pip install --no-build-isolation pyyaml==5.4.1" >> Dockerfile
8+
9+
# Run tests in docker
10+
docker build . -t test-foliant:latest
11+
12+
docker run --rm -it -v "./:/app/" -w /app/ test-foliant:latest "./test.sh"
13+
14+
# Remove Dockerfile
15+
rm Dockerfile

‎tests/test_backends.py

+158
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
from unittest import TestCase
2+
from pathlib import Path
3+
from tempfile import TemporaryDirectory
4+
5+
from foliant.backends.base import BaseBackend
6+
7+
class TestBackendCopyFiles(TestCase):
8+
def setUp(self):
9+
# Create a temporary directory for testing
10+
self.test_dir = TemporaryDirectory()
11+
self.source_dir = Path(self.test_dir.name) / "source"
12+
self.destination_dir = Path(self.test_dir.name) / "destination"
13+
self.source_dir.mkdir()
14+
self.destination_dir.mkdir()
15+
16+
# Create some test files
17+
(self.source_dir / "file1.txt").write_text("Hello, file1!")
18+
(self.source_dir / "file2.txt").write_text("Hello, file2!")
19+
(self.source_dir / "subfolder").mkdir()
20+
(self.source_dir / "subfolder" / "file3.txt").write_text("Hello, file3!")
21+
(self.source_dir / "file2.md").write_text("# Header\nSome content")
22+
(self.source_dir / "subfolder" / "file3.md").write_text("# Another Header\nMore content")
23+
24+
def tearDtown(self):
25+
# Clean up the temporary directory
26+
self.test_dir.cleanup()
27+
28+
def test_copy_single_file(self):
29+
# Test copying a single file
30+
source_file = self.source_dir / "file1.txt"
31+
BaseBackend.partial_copy(source_file, self.destination_dir, self.source_dir)
32+
33+
# Check if the file was copied
34+
self.assertTrue((self.destination_dir / "file1.txt").exists())
35+
self.assertEqual((self.destination_dir / "file1.txt").read_text(), "Hello, file1!")
36+
37+
def test_copy_list_of_files(self):
38+
# Test copying a list of files
39+
source_files = [
40+
self.source_dir / "file1.txt",
41+
self.source_dir / "file2.txt"
42+
]
43+
BaseBackend.partial_copy(source_files, self.destination_dir, self.source_dir)
44+
45+
# Check if the files were copied
46+
self.assertTrue((self.destination_dir / "file1.txt").exists())
47+
self.assertTrue((self.destination_dir / "file2.txt").exists())
48+
49+
50+
def test_copy_list_of_files_two(self):
51+
# Test copying a list of files
52+
source_files = [
53+
str(self.source_dir / "file1.txt"),
54+
str(self.source_dir / "file2.md")
55+
]
56+
BaseBackend.partial_copy(source_files, self.destination_dir, self.source_dir)
57+
58+
# Check if the files were copied
59+
self.assertTrue((self.destination_dir / "file1.txt").exists())
60+
self.assertTrue((self.destination_dir / "file2.md").exists())
61+
62+
def test_copy_glob_pattern(self):
63+
# Test copying files matching a glob pattern
64+
glob_pattern = str(self.source_dir / "*.txt")
65+
BaseBackend.partial_copy(glob_pattern, self.destination_dir, self.source_dir)
66+
67+
# Check if the files were copied
68+
self.assertTrue((self.destination_dir / "file1.txt").exists())
69+
self.assertTrue((self.destination_dir / "file2.txt").exists())
70+
self.assertFalse((self.destination_dir / "file3.txt").exists()) # file3 is in a subfolder
71+
72+
def test_copy_glob_pattern_md(self):
73+
# Test copying files matching a glob pattern
74+
glob_pattern = str(self.source_dir / '*2.md')
75+
BaseBackend.partial_copy(glob_pattern, self.destination_dir, self.source_dir)
76+
77+
# Check if the files were copied
78+
self.assertTrue((self.destination_dir / "file2.md").exists())
79+
self.assertEqual((self.destination_dir / "file2.md").read_text(), "# Header\nSome content")
80+
self.assertTrue((self.destination_dir / "subfolder" / "file3.md").exists())
81+
self.assertEqual((self.destination_dir / "subfolder" / "file3.md").read_text(), "# Another Header\n") # Only '*2.md' files should be copied with content
82+
83+
def test_copy_glob_pattern_recursive(self):
84+
# Test copying files matching a recursive glob pattern
85+
glob_pattern = str(self.source_dir / "**" / "*.txt")
86+
BaseBackend.partial_copy(glob_pattern, self.destination_dir, self.source_dir)
87+
88+
# Check if the files were copied, including the one in the subfolder
89+
self.assertTrue((self.destination_dir / "file1.txt").exists())
90+
self.assertTrue((self.destination_dir / "file2.txt").exists())
91+
self.assertTrue((self.destination_dir / "subfolder" / "file3.txt").exists())
92+
93+
def test_copy_directory_structure(self):
94+
# Test copying a file while preserving directory structure
95+
source_file = self.source_dir / "subfolder" / "file3.txt"
96+
BaseBackend.partial_copy(source_file, self.destination_dir, self.source_dir)
97+
98+
# Check if the file was copied with the directory structure
99+
self.assertTrue((self.destination_dir / "subfolder" / "file3.txt").exists())
100+
101+
def test_copy_nonexistent_file(self):
102+
# Test copying a nonexistent file (should raise FileNotFoundError)
103+
source_file = self.source_dir / "nonexistent.txt"
104+
with self.assertRaises(FileNotFoundError):
105+
BaseBackend.partial_copy(source_file, self.destination_dir, self.source_dir)
106+
107+
def test_copy_nonexistent_glob(self):
108+
# Test copying with a glob pattern that matches no files
109+
glob_pattern = str(self.source_dir / "*_suffix.md")
110+
BaseBackend.partial_copy(glob_pattern, self.destination_dir, self.source_dir)
111+
112+
# Check that no files were copied
113+
self.assertTrue((self.destination_dir / "file2.md").exists())
114+
self.assertEqual((self.destination_dir / "file2.md").read_text(), "# Header\n")
115+
self.assertTrue((self.destination_dir / "subfolder" / "file3.md").exists())
116+
self.assertEqual((self.destination_dir / "subfolder" / "file3.md").read_text(), "# Another Header\n")
117+
118+
def test_copy_to_nonexistent_destination(self):
119+
# Test copying to a nonexistent destination (should create the destination folder)
120+
new_destination = self.destination_dir / "new_folder"
121+
BaseBackend.partial_copy(self.source_dir / "file1.txt", new_destination, self.source_dir)
122+
123+
# Check if the file was copied and the destination folder was created
124+
self.assertTrue(new_destination.exists())
125+
self.assertTrue((new_destination / "file1.txt").exists())
126+
127+
def test_copy_path_object(self):
128+
# Test copying using Path objects
129+
source_file = self.source_dir / "file1.txt"
130+
destination = self.destination_dir / "file1.txt"
131+
BaseBackend.partial_copy(source_file, destination, self.source_dir)
132+
133+
# Check if the file was copied
134+
self.assertTrue(destination.exists())
135+
136+
def test_copy_text_file(self):
137+
# Test copying a text file
138+
BaseBackend.partial_copy(str(self.source_dir / "file1.txt"), self.destination_dir, self.source_dir)
139+
140+
# Check if the file was copied
141+
self.assertTrue((self.destination_dir / "file1.txt").exists())
142+
self.assertEqual((self.destination_dir / "file1.txt").read_text(), "Hello, file1!")
143+
144+
def test_copy_markdown_file(self):
145+
# Test copying a Markdown file
146+
BaseBackend.partial_copy(str(self.source_dir / "file2.md"), self.destination_dir, self.source_dir)
147+
148+
# Check if only the header was copied
149+
self.assertTrue((self.destination_dir / "file2.md").exists())
150+
self.assertEqual((self.destination_dir / "file2.md").read_text(), "# Header\nSome content")
151+
152+
def test_copy_directory_structure_with_header(self):
153+
# Test copying a file while preserving directory structure
154+
BaseBackend.partial_copy(str(self.source_dir / "subfolder" / "file3.md"), self.destination_dir, self.source_dir)
155+
156+
# Check if the file was copied with the directory structure
157+
self.assertTrue((self.destination_dir / "subfolder" / "file3.md").exists())
158+
self.assertEqual((self.destination_dir / "subfolder" / "file3.md").read_text(), "# Another Header\nMore content")

0 commit comments

Comments
 (0)
Please sign in to comment.