Skip to content

Commit d87bfb5

Browse files
committed
Fixed #22
- Implement a new command named 'collect_redist_src' - Have option to copy with or without directory structure - Able to take a CSV/JSON inventory as an input - Able to output to a zip file - Add some test code
1 parent 7c864a8 commit d87bfb5

File tree

9 files changed

+232
-41
lines changed

9 files changed

+232
-41
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,3 +49,4 @@ docs/_build
4949
/tcl/
5050
/.python-version
5151
/.tox/
52+
/.pytest_cache/

src/attributecode/cmd.py

Lines changed: 47 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,13 @@
3838
from attributecode.attrib import check_template
3939
from attributecode.attrib import DEFAULT_TEMPLATE_FILE
4040
from attributecode.attrib import generate_and_save as generate_attribution_doc
41-
from attributecode.gen import generate as generate_about_files
42-
from attributecode.model import collect_inventory
41+
from attributecode.gen import generate as generate_about_files, load_inventory
42+
from attributecode.model import collect_inventory, get_copy_list
4343
from attributecode.model import copy_redist_src
4444
from attributecode.model import write_output
4545
from attributecode.util import extract_zip
4646
from attributecode.util import filter_errors
47+
from attributecode.util import get_temp_dir
4748

4849

4950
__copyright__ = """
@@ -376,8 +377,21 @@ def attrib(location, output, template, vartext, quiet, verbose):
376377

377378
@click.argument('output',
378379
required=True,
379-
metavar='OUTPUT',
380-
type=click.Path(exists=True, file_okay=False, writable=True, resolve_path=True))
380+
metavar='OUTPUT')
381+
382+
@click.option('--from-inventory',
383+
metavar='FILE',
384+
type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
385+
help='Path to an inventory CSV/JSON file as the base list for files/directories '
386+
'that need to be copied which have the \'redistribute\' flagged.')
387+
388+
@click.option('--with-structures',
389+
is_flag=True,
390+
help='Copy sources with directory structure.')
391+
392+
@click.option('--zip',
393+
is_flag=True,
394+
help='Zip the copied sources to the output location.')
381395

382396
@click.option('-q', '--quiet',
383397
is_flag=True,
@@ -389,14 +403,21 @@ def attrib(location, output, template, vartext, quiet, verbose):
389403

390404
@click.help_option('-h', '--help')
391405

392-
def collect_redist_src(location, output, quiet, verbose):
406+
def collect_redist_src(location, output, from_inventory, with_structures, zip, quiet, verbose):
393407
"""
394-
Collect sources that have 'redistribute' flagged to the output location.
408+
Collect sources that have 'redistribute' flagged in .ABOUT files or inventory
409+
to the output location.
395410
396-
LOCATION: Path to a file or directory containing .ABOUT files.
411+
LOCATION: Path to a directory containing sources that need to be copied
412+
(and containing ABOUT files if `inventory` is not provided)
397413
398-
OUTPUT: Path to a directory where sources will be copied to.
414+
OUTPUT: Path to a directory or a zip file where sources will be copied to.
399415
"""
416+
if zip:
417+
if not output.endswith('.zip'):
418+
click.echo('The output needs to be a zip file.')
419+
sys.exit()
420+
400421
if not quiet:
401422
print_version()
402423
click.echo('Collecting inventory from ABOUT files...')
@@ -405,9 +426,25 @@ def collect_redist_src(location, output, quiet, verbose):
405426
# accept zipped ABOUT files as input
406427
location = extract_zip(location)
407428

408-
errors, abouts = collect_inventory(location)
409-
copy_errors = copy_redist_src(abouts, location, output)
429+
if from_inventory:
430+
errors, abouts = load_inventory(from_inventory, location)
431+
else:
432+
errors, abouts = collect_inventory(location)
433+
434+
if zip:
435+
# Copy to a temp location and the zip to the ouput location
436+
output_location = get_temp_dir()
437+
else:
438+
output_location = output
439+
440+
copy_list, copy_list_errors = get_copy_list(abouts, location)
441+
copy_errors = copy_redist_src(copy_list, location, output_location, with_structures)
442+
443+
if zip:
444+
import shutil
445+
shutil.make_archive(output, 'zip', output_location)
410446

447+
errors.extend(copy_list_errors)
411448
errors.extend(copy_errors)
412449
errors_count = report_errors(errors, quiet, verbose, log_file_loc=output + '-error.log')
413450
if not quiet:

src/attributecode/model.py

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1271,12 +1271,45 @@ def get_field_names(abouts):
12711271
return fields
12721272

12731273

1274-
def copy_redist_src(abouts, location, output):
1274+
def copy_redist_src(copy_list, location, output, with_structure):
12751275
"""
1276-
Given a list of About objects, copy the referenced source (file or directory)
1277-
to the output location if the 'redistribute' field is set to True.
1276+
Given a list of files/directories and copy to the destination
12781277
"""
12791278
errors = []
1279+
for from_path in copy_list:
1280+
norm_from_path = norm(from_path)
1281+
relative_from_path = norm_from_path.partition(util.norm(location))[2]
1282+
# Need to strip the '/' to use the join
1283+
if relative_from_path.startswith('/'):
1284+
relative_from_path = relative_from_path.partition('/')[2]
1285+
# Get the directory name of the output path
1286+
if with_structure:
1287+
output_dir = os.path.dirname(os.path.join(output, util.norm(relative_from_path)))
1288+
else:
1289+
output_dir = output
1290+
err = copy_file(from_path, output_dir)
1291+
if err:
1292+
errors.extend(err)
1293+
return errors
1294+
1295+
1296+
def get_copy_list(abouts, location):
1297+
"""
1298+
Return a list of files/directories that need to be copied (and error if any)
1299+
This is a summary list in a sense that if a directory is already in the list,
1300+
its children directories/files will not be included in the list regardless if
1301+
they have 'redistribute' flagged. The reason for this is we want to capture
1302+
the error/warning if existence files/directories already exist. However, if
1303+
we don't have this "summarized" list, and we've copied a file (with directory structure)
1304+
and then later on this file's parent directory also need to be copied, then
1305+
it will prompt warning as the directory that need to be copied is already exist.
1306+
Technically, this is correct, but it leads to confusion. Therefore, we want to
1307+
create a summarized list to avoid this kind of confusion.
1308+
"""
1309+
errors = []
1310+
copy_list = []
1311+
dir_list = []
1312+
file_list = []
12801313
for about in abouts:
12811314
if about.redistribute.value:
12821315
file_exist = True
@@ -1290,17 +1323,57 @@ def copy_redist_src(abouts, location, output):
12901323
for k in about.about_resource.value:
12911324
from_path = about.about_resource.value.get(k)
12921325
norm_from_path = norm(from_path)
1326+
# Get the relative path
12931327
relative_from_path = norm_from_path.partition(util.norm(location))[2]
1294-
# Need to strip the '/' to use the join
1295-
if relative_from_path.startswith('/'):
1296-
relative_from_path = relative_from_path.partition('/')[2]
1297-
# Get the directory name of the output path
1298-
output_dir = os.path.dirname(os.path.join(output, util.norm(relative_from_path)))
1299-
err = copy_file(from_path, output_dir)
1300-
if err:
1301-
errors.extend(err)
1302-
return errors
1328+
if os.path.isdir(from_path):
1329+
if not dir_list:
1330+
dir_list.append(relative_from_path)
1331+
else:
1332+
handled = False
1333+
for dir in dir_list:
1334+
# The dir is a parent of the relative_from_path
1335+
if dir in relative_from_path:
1336+
handled = True
1337+
continue
1338+
# The relative_from_path is the parent of the dir
1339+
# We need to update the dir_list
1340+
if relative_from_path in dir:
1341+
dir_list.remove(dir)
1342+
dir_list.append(relative_from_path)
1343+
handled = True
1344+
continue
1345+
if not handled:
1346+
dir_list.append(relative_from_path)
1347+
else:
1348+
# Check if the file is from "root"
1349+
# If the file is at root level, it'll add to the copy_list
1350+
if not os.path.dirname(relative_from_path) == '/':
1351+
file_list.append(relative_from_path)
1352+
else:
1353+
copy_list.append(from_path)
1354+
1355+
for dir in dir_list:
1356+
for f in file_list:
1357+
# The file is already in one of copied directories
1358+
if dir in f:
1359+
file_list.remove(f)
1360+
continue
1361+
if dir.startswith('/'):
1362+
dir = dir.partition('/')[2]
1363+
absolute_path = os.path.join(location, dir)
1364+
if on_windows:
1365+
absolute_path = add_unc(absolute_path)
1366+
copy_list.append(absolute_path)
1367+
1368+
for f in file_list:
1369+
if f.startswith('/'):
1370+
f = f.partition('/')[2]
1371+
absolute_path = os.path.join(location, f)
1372+
if on_windows:
1373+
absolute_path = add_unc(absolute_path)
1374+
copy_list.append(absolute_path)
13031375

1376+
return copy_list, errors
13041377

13051378
def about_object_to_list_of_dictionary(abouts):
13061379
"""

src/attributecode/util.py

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
import string
2929
import sys
3030

31+
from distutils.dir_util import copy_tree
32+
3133
from attributecode import CRITICAL
3234
from attributecode import WARNING
3335
from attributecode import Error
@@ -478,6 +480,7 @@ def copy_license_notice_files(fields, base_dir, reference_dir, afp):
478480

479481

480482
def copy_file(from_path, to_path):
483+
error = []
481484
# Return if the from_path is empty or None.
482485
if not from_path:
483486
return []
@@ -489,11 +492,10 @@ def copy_file(from_path, to_path):
489492
to_path = add_unc(to_path)
490493

491494
# Strip the white spaces
492-
from_path = from_path.strip()
495+
from_path = norm(from_path.strip())
493496
to_path = to_path.strip()
494-
495497
# Errors will be captured when doing the validation
496-
if not posixpath.exists(from_path):
498+
if not os.path.exists(from_path):
497499
return []
498500

499501
if not posixpath.exists(to_path):
@@ -503,18 +505,21 @@ def copy_file(from_path, to_path):
503505
# Copy the whole directory structure
504506
folder_name = os.path.basename(from_path)
505507
to_path = os.path.join(to_path, folder_name)
506-
# Since we need to copy everything along with the directory structure,
507-
# making sure the directory does not exist will not hurt.
508508
if os.path.exists(to_path):
509-
shutil.rmtree(to_path)
510-
# Copy the directory recursively along with its structure
511-
shutil.copytree(from_path, to_path)
509+
msg = norm(to_path)+ ' is already existed and is replaced by ' + norm(from_path)
510+
error.append(Error(WARNING, msg))
511+
copy_tree(from_path, to_path)
512512
else:
513+
file_name = os.path.basename(from_path)
514+
to_file_path = os.path.join(to_path, file_name)
515+
if os.path.exists(to_file_path):
516+
msg = norm(to_file_path)+ ' is already existed and is replaced by ' + norm(from_path)
517+
error.append(Error(WARNING, msg))
513518
shutil.copy2(from_path, to_path)
514-
return []
519+
return error
515520
except Exception as e:
516521
msg = 'Cannot copy file at %(from_path)r.' % locals()
517-
error = [Error(CRITICAL, msg)]
522+
error.append(Error(CRITICAL, msg))
518523
return error
519524

520525

@@ -628,6 +633,44 @@ def filter_errors(errors, minimum_severity=WARNING):
628633
return unique([e for e in errors if e.severity >= minimum_severity])
629634

630635

636+
def create_dir(location):
637+
"""
638+
Create directory or directory tree at location, ensuring it is readable
639+
and writeable.
640+
"""
641+
import stat
642+
if not os.path.exists(location):
643+
os.makedirs(location)
644+
os.chmod(location, stat.S_IRWXU | stat.S_IRWXG
645+
| stat.S_IROTH | stat.S_IXOTH)
646+
647+
648+
def get_temp_dir(sub_dir_path=None):
649+
"""
650+
Create a unique new temporary directory location. Create directories
651+
identified by sub_dir_path if provided in this temporary directory.
652+
Return the location for this unique directory joined with the
653+
sub_dir_path if any.
654+
"""
655+
new_temp_dir = build_temp_dir()
656+
657+
if sub_dir_path:
658+
# create a sub directory hierarchy if requested
659+
new_temp_dir = os.path.join(new_temp_dir, sub_dir_path)
660+
create_dir(new_temp_dir)
661+
return new_temp_dir
662+
663+
664+
def build_temp_dir(prefix='attributecode-'):
665+
"""
666+
Create and return a new unique empty directory created in base_dir.
667+
"""
668+
import tempfile
669+
location = tempfile.mkdtemp(prefix=prefix)
670+
create_dir(location)
671+
return location
672+
673+
631674
"""
632675
Return True if a string s name is safe to use as an attribute name.
633676
"""

tests/test_model.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
from attributecode import WARNING
3636
from attributecode import Error
3737
from attributecode import model
38-
from attributecode.util import add_unc
38+
from attributecode.util import add_unc, norm, on_windows
3939
from attributecode.util import load_csv
4040
from attributecode.util import to_posix
4141
from attributecode.util import replace_tab_with_spaces
@@ -1210,13 +1210,16 @@ def test_collect_inventory_does_not_convert_lf_to_crlf_from_directory(self):
12101210
expected = get_test_loc('test_model/crlf/expected.csv')
12111211
check_csv(expected, result, fix_cell_linesep=True, regen=False)
12121212

1213-
def test_copy_redist_src(self):
1214-
test_loc = get_test_loc('test_model/redistribution')
1213+
def test_copy_redist_src_no_structure(self):
1214+
test_loc = get_test_loc('test_model/redistribution/')
1215+
copy_list = [get_test_loc('test_model/redistribution/this.c'), get_test_loc('test_model/redistribution/test/subdir')]
12151216
output = get_temp_dir()
1216-
errors, abouts = model.collect_inventory(test_loc)
1217-
expected_file = ['this.c']
12181217

1219-
err = model.copy_redist_src(abouts, test_loc, output)
1218+
expected_file = ['this.c', 'subdir']
1219+
1220+
with_structure = False
1221+
err = model.copy_redist_src(copy_list, test_loc, output, with_structure)
1222+
12201223
assert err == []
12211224

12221225
from os import listdir
@@ -1226,6 +1229,37 @@ def test_copy_redist_src(self):
12261229
for file in expected_file:
12271230
assert file in copied_files
12281231

1232+
def test_copy_redist_src_with_structure(self):
1233+
test_loc = get_test_loc('test_model/redistribution/')
1234+
copy_list = [get_test_loc('test_model/redistribution/this.c'), get_test_loc('test_model/redistribution/test/subdir')]
1235+
output = get_temp_dir()
1236+
1237+
expected_file = ['this.c', 'test']
1238+
1239+
with_structure = True
1240+
err = model.copy_redist_src(copy_list, test_loc, output, with_structure)
1241+
1242+
assert err == []
1243+
1244+
from os import listdir
1245+
copied_files = listdir(output)
1246+
assert len(expected_file) == len(copied_files)
1247+
assert err == []
1248+
for file in expected_file:
1249+
assert file in copied_files
1250+
1251+
def test_get_copy_list(self):
1252+
location = get_test_loc('test_model/redistribution/')
1253+
result = get_temp_file()
1254+
errors, abouts = model.collect_inventory(location)
1255+
copy_list, err = model.get_copy_list(abouts, location)
1256+
assert err == []
1257+
norm_list = []
1258+
for c in copy_list:
1259+
norm_list.append(norm(c))
1260+
expected = [os.path.join(location, 'this.c'), os.path.join(location, 'test/subdir')]
1261+
assert norm_list == expected
1262+
12291263
class FetchLicenseTest(unittest.TestCase):
12301264

12311265
@mock.patch.object(model, 'urlopen')
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
about_resource: .
2+
name: test
3+
redistribute: x

tests/testdata/test_model/redistribution/test/subdir/test.c

Whitespace-only changes.

0 commit comments

Comments
 (0)