damage 0.3.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
damage/__init__.py ADDED
@@ -0,0 +1,444 @@
1
+ '''
2
+ Manifest generator for data files.
3
+
4
+ Produces a text file with user specificied checksums for all files
5
+ from the top of a specified tree and checks line length
6
+ and ASCII character status for text files.
7
+
8
+ For statistics program files:
9
+ SAS .sas7bdat
10
+ SPSS .sav
11
+ Stata .dta
12
+
13
+ Checker() will report number of cases and variables as
14
+ rows and columns respectively.
15
+
16
+ '''
17
+
18
+ import copy
19
+ import csv
20
+ import hashlib
21
+ import io
22
+ import json
23
+ import logging
24
+ import mimetypes
25
+ import pathlib
26
+ import string
27
+
28
+ import chardet
29
+ import pyreadstat
30
+
31
+ LOGGER = logging.getLogger()
32
+
33
+ VERSION = (0, 3, 14)
34
+ __version__ = '.'.join([str(x) for x in VERSION])
35
+
36
+ #PDB note check private variables with self._Checker__private_var
37
+ #Note *single* underscore before Checker
38
+ class Checker():
39
+ '''
40
+ A collection of various tools attached to a file
41
+ '''
42
+
43
+ def __init__(self, fname: str) -> None: #DONE
44
+ '''
45
+ Initializes Checker instance
46
+
47
+ fname : str
48
+ Path to file
49
+ '''
50
+ #Commercial stats files extensions
51
+ #I am aware that extension checking is not perfect
52
+ self.statfiles = ['.dta', '.sav', '.sas7bdat']
53
+ #brute force is best force
54
+ self.textfiles= ['.dat', '.txt', '.md', '.csv',
55
+ '.tsv', '.asc', '.html', '.xml',
56
+ '.xsd', '.htm', '.log', '.nfo',
57
+ '.text', '.xsl', '.py', '.r',
58
+ '.toml', '.yaml', '.yml']
59
+ self.fname = pathlib.Path(fname)
60
+ #self._ext = fname.suffix
61
+ self.__istext = self.__istextfile()
62
+ self.__text_obj = None
63
+ with open(self.fname, 'rb') as fil:
64
+ self.__fobj_bin = io.BytesIO(fil.read())
65
+ self.encoding = self.__encoding()
66
+ if self.__istext:
67
+ with open(self.fname, encoding=self.encoding.get('encoding')) as f:
68
+ self.__text_obj = io.StringIO(f.read())
69
+
70
+
71
+ @property
72
+ def hidden(self)->bool:
73
+ '''
74
+ Returns True if file is hidden (ie, startswith '.')
75
+ or is in in a hidden directory (ie, any directory on the path
76
+ starts with '.')
77
+ '''
78
+ if any([x.startswith('.') for x in self.fname.parts]):
79
+ return True
80
+ return False
81
+
82
+ def __istextfile(self):
83
+ '''
84
+ Check to see if file is a text file based on mimetype.
85
+ Works with extensions only which is not ideal
86
+ '''
87
+ try:
88
+ if ('text' in mimetypes.guess_file_type(self.fname)
89
+ or self.fname.suffix.lower() in self.textfiles):
90
+ return True
91
+ except AttributeError: #soft deprecation fix
92
+ if ('text' in mimetypes.guess_type(self.fname)
93
+ or self.fname.suffix.lower() in self.textfiles):
94
+ return True
95
+
96
+ return False
97
+
98
+ def __encoding(self) -> dict: #DONE
99
+ '''
100
+ Returns most likely encoding of self.fname, dict with keys
101
+ encoding, confidence, language (the output of chardet.detect)
102
+ and sets Checker.__is_text
103
+ '''
104
+ enc = chardet.detect(self.__fobj_bin.read())
105
+ self.__fobj_bin.seek(0) #leave it as you found it
106
+ if self.__istext:
107
+ return enc
108
+
109
+ return {'encoding': None,
110
+ 'confidence': 0.0,
111
+ 'language' : ''}
112
+
113
+ def __del__(self) -> None:#DONE
114
+ '''
115
+ Destructor closes file
116
+ '''
117
+ self.__fobj_bin.close()
118
+
119
+ def produce_digest(self, prot: str = 'md5', blocksize: int = 2*16) -> str: #DONE
120
+ '''
121
+ Returns hex digest for object
122
+
123
+ fname : str
124
+ Path to a file object
125
+
126
+ prot : str
127
+ Hash type. Supported hashes: 'sha1', 'sha224', 'sha256',
128
+ 'sha384', 'sha512', 'blake2b', 'blake2s', 'md5'.
129
+ Default: 'md5'
130
+
131
+ blocksize : int
132
+ Read block size in bytes
133
+ '''
134
+ ok_hash = {'sha1' : hashlib.sha1(),
135
+ 'sha224' : hashlib.sha224(),
136
+ 'sha256' : hashlib.sha256(),
137
+ 'sha384' : hashlib.sha384(),
138
+ 'sha512' : hashlib.sha512(),
139
+ 'blake2b' : hashlib.blake2b(),
140
+ 'blake2s' : hashlib.blake2s(),
141
+ 'md5': hashlib.md5()}
142
+
143
+ self.__fobj_bin.seek(0)
144
+ try:
145
+ _hash = ok_hash[prot]
146
+ except (UnboundLocalError, KeyError):
147
+ message = ('Unsupported hash type. Valid values are '
148
+ f'{list(ok_hash)}.')
149
+ LOGGER.exception('Unsupported hash type. Valid values are %s', message)
150
+ raise
151
+
152
+ fblock = self.__fobj_bin.read(blocksize)
153
+ while fblock:
154
+ _hash.update(fblock)
155
+ fblock = self.__fobj_bin.read(blocksize)
156
+ return _hash.hexdigest()
157
+
158
+ def flat_tester(self, **kwargs) -> dict: #DONE
159
+ '''
160
+ Checks file for line length and number of records.
161
+
162
+ Returns a dictionary:
163
+
164
+ `{'min_cols': int, 'max_cols' : int, 'numrec':int, 'constant' : bool}`
165
+ '''
166
+ if not kwargs.get('flatfile'):
167
+ return {'min_cols': 'N/A', 'max_cols': 'N/A', 'numrec' : 'N/A',
168
+ 'constant': 'N/A', 'encoding' : 'N/A'}
169
+
170
+ if self.fname.suffix.lower() in self.statfiles:
171
+ return self._flat_tester_commercial(**kwargs)
172
+
173
+ if self.__istext:
174
+ return self._flat_tester_txt()
175
+ #this should not happen but you never know
176
+ return {'min_cols': 'N/A', 'max_cols': 'N/A', 'numrec' : 'N/A',
177
+ 'constant': 'N/A', 'encoding' : 'N/A'}
178
+
179
+ def _flat_tester_commercial(self, **kwargs) -> dict: #DONE
180
+ '''
181
+ Checks SPSS sav, SAS sas7bdat and Stata .dta files for rectangularity
182
+
183
+ Returns a dictionary:
184
+
185
+ `{'min_cols': int, 'max_cols': int, 'numrec' : int,
186
+ 'constant': True, 'encoding': str}`
187
+
188
+ These files are by definition rectanglar, at least as checked here
189
+ by pyreadstat/pandas, so constant will always == True.
190
+ '''
191
+ if not kwargs.get('flatfile'):
192
+ return {'min_cols': 'N/A', 'max_cols': 'N/A', 'numrec' : 'N/A',
193
+ 'constant': 'N/A', 'encoding': 'N/A'}
194
+ options = {'.sav' : pyreadstat.read_sav,
195
+ '.dta' : pyreadstat.read_dta,
196
+ '.sas7bdat' : pyreadstat.read_sas7bdat}
197
+ meta = options[self.fname.suffix.lower()](self.fname)[1]
198
+ #self._encoding = meta.file_encoding
199
+ self.encoding['encoding'] = meta.file_encoding
200
+ return {'min_cols':meta.number_columns,
201
+ 'max_cols':meta.number_columns,
202
+ 'numrec': meta.number_rows,
203
+ 'constant':True,
204
+ 'encoding': self.encoding['encoding']}
205
+
206
+ def _flat_tester_txt(self) -> dict: #DONE
207
+ '''
208
+ Checks file for line length and number of records.
209
+
210
+ Returns a dictionary:
211
+
212
+ `{'min_cols': int, 'max_cols' : int, 'numrec':int, 'constant' : bool}`
213
+ '''
214
+ linecount = 0
215
+ self.__text_obj.seek(0)
216
+ if not self.__istext:
217
+ raise TypeError('Not a text file')
218
+ maxline = len(self.__text_obj.readline())
219
+ minline = maxline
220
+ orig = maxline # baseline to which new values are compared
221
+ for row in self.__text_obj.readlines():
222
+ linecount += 1
223
+ maxline = max(maxline, len(row))
224
+ minline = min(minline, len(row))
225
+ constant = bool(maxline == orig == minline)
226
+ self.__text_obj.seek(0)
227
+ return {'min_cols': minline, 'max_cols': maxline, 'numrec' : linecount,
228
+ 'constant': constant, 'encoding': self.encoding['encoding']}
229
+
230
+ def non_ascii_tester(self, **kwargs) -> list: #DONE
231
+ '''
232
+ Returns a list of dicts of positions of non-ASCII characters in a text file.
233
+
234
+ `[{'row': int, 'col':int, 'char':str}...]`
235
+
236
+ fname : str
237
+ Path/filename
238
+
239
+ Keyword arguments:
240
+
241
+ #flatfile : bool
242
+ asctest : bool
243
+ — Perform character check (assuming it is text)
244
+ '''
245
+ if (kwargs.get('asctest', False)
246
+ or not self.__istext
247
+ or not kwargs.get('flatfile')):
248
+ return []
249
+ outlist = []
250
+ self.__text_obj.seek(0)
251
+ for rown, row in enumerate(self.__text_obj):
252
+ for coln, char in enumerate(row):
253
+ if char not in string.printable and char != '\x00':
254
+ non_asc = {'row':rown+1, 'col': coln+1, 'char':char}
255
+ outlist.append(non_asc)
256
+ self.__text_obj.seek(0)
257
+ return outlist
258
+
259
+ def null_count(self, **kwargs) -> dict: #DONE
260
+ '''
261
+ Returns an integer count of null characters in the file
262
+ ('\x00') or None if skipped
263
+
264
+ Keyword arguments:
265
+
266
+ flatfile : bool
267
+ — Test is useless if not a text file. If False, returns 'N/A'
268
+ '''
269
+ if (not kwargs.get('flatfile')
270
+ or not self.__istext
271
+ or not kwargs.get('null_chars')):
272
+ return None
273
+ self.__text_obj.seek(0)
274
+ count = self.__text_obj.read().count('\x00')
275
+ if not count:
276
+ return None
277
+ return count
278
+
279
+ def dos(self, **kwargs) -> bool: #DONE
280
+ '''
281
+ Checks for presence of carriage returns in file
282
+
283
+ Returns True if a carriage return ie, ord(13) is present
284
+
285
+ Keyword arguments:
286
+
287
+ flatfile : bool
288
+ — Perform rectangularity check. If False, returns dictionary
289
+ with all values as 'N/A'
290
+ '''
291
+ if not kwargs.get('flatfile') or not self.__istext:
292
+ return None
293
+ self.__fobj_bin.seek(0)
294
+ for text in self.__fobj_bin:
295
+ if b'\r\n' in text:
296
+ return True
297
+ return False
298
+
299
+ def _mime_type(self, fname:pathlib.Path)->tuple:
300
+ '''
301
+ Returns mimetype or 'application/octet-stream'
302
+ '''
303
+ try:
304
+ out = mimetypes.guess_file_type(fname, strict=False)[0]
305
+ except AttributeError:
306
+ #soft deprecation
307
+ out = mimetypes.guess_type(fname)[0]
308
+ if not out:
309
+ out = 'application/octet-stream'
310
+ return out
311
+
312
+ def _report(self, **kwargs) -> dict: #DONE
313
+ '''
314
+ Returns a dictionary of outputs based on keywords below.
315
+ Performs each test and returns the appropriate values. A convenience
316
+ function so that you don't have to run the tests individually.
317
+
318
+ Sample output:
319
+
320
+ ```
321
+ {'filename':'/tmp/test.csv',
322
+ 'flat': True,
323
+ 'min_cols': 100, 'max_cols': 100, 'numrec' : 101, 'constant': True,
324
+ 'nonascii':False,
325
+ 'dos':False}
326
+ ```
327
+ Accepted keywords and defaults:
328
+ digest : str
329
+ — Hash algorithm. Default 'md5'
330
+
331
+ flat : bool
332
+ — Flat file checking.
333
+
334
+ nonascii : bool
335
+ — Check for non-ASCII characters.
336
+
337
+ flatfile : bool
338
+ — Perform rectangularity check. If False, returns dictionary
339
+ with all values as 'N/A'
340
+
341
+ null_chars : bool
342
+ - check for null characters
343
+ '''
344
+ out = {'filename': self.fname}
345
+ digest = kwargs.get('digest', 'md5')
346
+ #dos = kwargs.get('dos')
347
+
348
+ out.update({'digestType' : digest})
349
+ out.update({'digest' : self.produce_digest(digest)})
350
+ #out.update({'flat': self.flat_tester(**kwargs)})
351
+ out.update(self.flat_tester(**kwargs))
352
+ #out.update({'flat':'FFFFFFFFFFFF'})
353
+ out.update({'nonascii': self.non_ascii_tester(**kwargs)})
354
+ out.update({'encoding': self.encoding['encoding']})
355
+ out.update({'null_chars': self.null_count(**kwargs)})
356
+ out.update({'mimetype': self._mime_type(self.fname)})
357
+ #if dos:
358
+ # out.update({'dos' : self.dos(**kwargs)})
359
+ #else:
360
+ # out.update({'dos': None})
361
+ out.update({'dos': self.dos(**kwargs)})
362
+ return out
363
+
364
+ def _manifest_txt(self, **kwargs)->str:
365
+ '''
366
+ Returns manifest as plain text
367
+ '''
368
+ return '\n'.join([f'{k}: {v}' for k,v in kwargs['report'].items()
369
+ if v not in ['', None]])
370
+
371
+ def _manifest_json(self, **kwargs)->str:
372
+ '''
373
+ Returns manifest as JSON
374
+ '''
375
+ out = kwargs['report'].copy()
376
+ out['filename'] = str(kwargs['report']['filename'])
377
+ return json.dumps(out)
378
+
379
+ def _manifest_csv(self, **kwargs)->str:
380
+ '''
381
+ Returns manifest as [whatever]-separated value
382
+ '''
383
+ outstr = io.StringIO(newline='')
384
+ writer = csv.DictWriter(outstr, fieldnames=kwargs['report'].keys(),
385
+ delimiter=kwargs.get('sep', ','),
386
+ quoting=csv.QUOTE_MINIMAL)
387
+ if kwargs.get('headers'):
388
+ writer.writeheader()
389
+ writer.writerow(kwargs['report'])
390
+ outstr.seek(0)
391
+ return outstr.read()
392
+
393
+ def manifest(self, **kwargs) -> str: #really as str #DONE
394
+ '''
395
+ Returns desired output type as string
396
+
397
+ out : str
398
+ — Acceptable values are 'txt', 'json', 'csv'
399
+ 'txt' Plain text
400
+ 'json' JSON
401
+ 'csv' Comma-separated value
402
+
403
+ Accepted keywords and defaults:
404
+
405
+ digest : str
406
+ — Hash algorithm. Default 'md5'
407
+
408
+ flat : bool
409
+ — Flat file checking. Default True
410
+
411
+ nonascii : bool
412
+ — Check for non-ASCII characters. Default True
413
+
414
+ dos : bool
415
+ — check for Windows CR/LF combo. Default True
416
+
417
+ flatfile : bool
418
+ — Perform rectangularity check. If False, returns dictionary
419
+ with all values as 'N/A'
420
+
421
+ headers : bool
422
+ — Include csv header (only has any effect with out='csv')
423
+ Default is False
424
+
425
+ sep: str
426
+ — Separator if you want a different plain text separator like a
427
+ tab (\t) or pipe (|). Only functional with csv output, obviously.
428
+
429
+ '''
430
+ report = self._report(**kwargs)
431
+ report_type={'txt': self._manifest_txt,
432
+ 'json': self._manifest_json,
433
+ 'csv': self._manifest_csv,
434
+ 'tsv': self._manifest_csv,
435
+ 'psv': self._manifest_csv}
436
+
437
+ try:
438
+ return report_type[kwargs['out']](report=report, **kwargs)
439
+ except KeyError:
440
+ LOGGER.error('Unsupported manifest type %s; defaulting to text', kwargs['out'])
441
+ return report_type[kwargs['out']](report=report, out='txt', **kwargs)
442
+
443
+ if __name__ == '__main__':
444
+ pass
@@ -0,0 +1,126 @@
1
+ '''
2
+ Manifest generator for data files.
3
+
4
+ Produces a text file with user specified checksums for all files
5
+ from the top of a specified tree and checks line length
6
+ and ASCII character status for text files.
7
+
8
+ For statistics program files:
9
+ SAS .sas7bdat
10
+ SPSS .sav
11
+ Stata .dta
12
+
13
+ Checker() will report number of cases and variables as
14
+ rows and columns respectively.
15
+
16
+ '''
17
+
18
+ import argparse
19
+ import glob #God I hate Windows
20
+ import json
21
+ import os
22
+ import pathlib
23
+ import sys
24
+
25
+ import damage
26
+
27
+ def parse() -> argparse.ArgumentParser(): #DONE
28
+ '''
29
+ Separates argparser into function. Returns arparse.ArgumentParser()
30
+ '''
31
+ desc = ('Produces a text, csv or JSON output with checksums for files, '
32
+ 'testing for Windows CRLF combinations, '
33
+ 'as well as checking text files for regularity and non/ASCII characters')
34
+ parser = argparse.ArgumentParser(description=desc)
35
+ parser.add_argument('files', help='Files to check. Wildcards acceptable (eg, *)',
36
+ nargs='+', default=' ')
37
+ #note 'prog' is built into argparse
38
+ parser.add_argument('-v', '--version', action='version', version='%(prog)s '+damage.__version__,
39
+ help='Show version number and exit')
40
+ parser.add_argument('-o', '--output', dest='out',
41
+ help='Output format. One of txt, csv, json, tsv',
42
+ default='txt',
43
+ choices = ['txt', 'csv', 'tsv', 'json'],
44
+ type=str.lower)
45
+ parser.add_argument('-n', '--no-flat', action='store_false', dest='flatfile',
46
+ help="Don't check text files for rectangularity")
47
+ parser.add_argument('-r', '--recursive', action='store_true', dest='recur',
48
+ help='Recursive *directory* processing of file tree. Assumes that the '
49
+ 'arguments point to a directory (eg, tmp/), and a slash will '
50
+ 'be appended if one does not exist')
51
+ parser.add_argument('-t', '--hash-type', dest='digest', default='md5',
52
+ help="Checksum hash type. Supported hashes: 'sha1', "
53
+ "'sha224', 'sha256', 'sha384', 'sha512', 'blake2b', "
54
+ "'blake2s', 'md5'. Default: 'md5'",
55
+ choices = ['md5', 'sha1', 'sha224', 'sha256', 'sha384', 'sha512',
56
+ 'blake2b', 'blake2s'],
57
+ type=str.lower)
58
+ parser.add_argument('-a', '--no-ascii', action='store_true', dest='asctest',
59
+ help="Don't check text files for non-ASCII characters")
60
+ parser.add_argument('-f', '--to-file',
61
+ help='Output to -f [file] instead of stdout')
62
+ return parser
63
+
64
+ def recurse_files(inlist) -> map:
65
+ '''
66
+ Returns a map object with pathlib.Paths of files
67
+ '''
68
+ outlist = []
69
+ for flist in inlist:
70
+ rec = os.walk(flist)
71
+ outlist += [pathlib.Path(x[0], y) for x in rec for y in x[2]]
72
+ return outlist #includes hidden files
73
+
74
+ def main(): #pylint: disable=too-many-branches
75
+ '''
76
+ Main function to output manifests to stdout.
77
+ '''
78
+ separator_types = {'csv': ',', 'tsv': '\t'}
79
+ #Purely for formatting output
80
+ line_spacer = {'txt':'\n\n', 'csv':'', 'tsv': ''}
81
+ parser = parse()
82
+ args = parser.parse_args()
83
+ if not args.recur:
84
+ #Windows does not do wildcard expansion at the shell level
85
+ if sys.platform.startswith('win'): #Maybe they will have win64 sometime:
86
+ files = map(pathlib.Path, [y for x in args.files for y in glob.glob(x)])
87
+ else:
88
+ files = map(pathlib.Path, list(args.files))
89
+ else:
90
+ files = recurse_files(args.files)
91
+
92
+
93
+ output = []
94
+ try: ###
95
+ for num, fil in enumerate(files):
96
+ if not fil.is_file() or not fil.exists():
97
+ continue
98
+ testme = damage.Checker(fil)
99
+ if args.out in separator_types and num == 0:
100
+ output.append(testme.manifest(headers=True,
101
+ sep=separator_types.get(args.out),
102
+ **vars(args)))
103
+ else:
104
+ output.append(testme.manifest(sep=separator_types.get(args.out),
105
+ **vars(args)))
106
+ if not args.out == 'json':
107
+ #print(line_spacer[args.out].join(output).strip())
108
+ out_info =line_spacer[args.out].join(output).strip()
109
+ else:
110
+ outjson = ('{"files" :' +
111
+ '[' + ','.join(output) + ']'
112
+ + '}')
113
+ out_info = json.dumps(json.loads(outjson)) #validate
114
+ except Exception as err: #pylint: disable=broad-exception-caught
115
+ print(f'Abnormal program termination {err}')
116
+ sys.exit()
117
+
118
+ if args.to_file:
119
+ with open(pathlib.Path(args.to_file), mode='w',
120
+ encoding='utf-8') as outf:
121
+ outf.write(out_info)
122
+ else:
123
+ print(out_info)
124
+
125
+ if __name__ == '__main__':
126
+ main()
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,10 @@
1
+ MIT License
2
+
3
+ Copyright 2025 University of British Columbia Library
4
+
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,678 @@
1
+ '''
2
+ Damage GUI application
3
+ '''
4
+ #import base64
5
+ import base64
6
+ import json
7
+ import os
8
+ import pathlib
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import textwrap
15
+ import webbrowser
16
+ import FreeSimpleGUI as sg
17
+
18
+ import damage
19
+ #pylint: disable = possibly-used-before-assignment
20
+
21
+ #TODO put the application into a class or function
22
+ #Prettify the name
23
+ PROGNAME = pathlib.Path(__file__).stem.capitalize().replace('_gui','')
24
+ __version__ = '.'.join([str(x) for x in damage.VERSION])
25
+
26
+ BASEDIR = pathlib.Path(__file__).parent
27
+
28
+ with open(pathlib.Path(BASEDIR, 'assets', 'DamageAppIcon.png'),
29
+ 'rb') as f:
30
+ ICON = base64.b64encode(f.read())
31
+
32
+ with open(pathlib.Path(BASEDIR, 'assets', 'LICENCE.txt'),
33
+ mode='r', encoding='utf-8') as lic:
34
+ LICENCE = textwrap.fill(lic.read(), width=70,
35
+ replace_whitespace=False)
36
+
37
+ sg.DEFAULT_WINDOW_ICON = ICON
38
+
39
+ if sg.running_mac():
40
+ import plistlib
41
+ TTK_THEME = 'aqua'
42
+ # make FONTSIZE dependent on homebrew in path because only brew python
43
+ # compiles successfully on MacOS on a case-insensitive file system
44
+ # and for some inconceivable reason the font size changes
45
+ FONTSIZE = 14
46
+ for hack in ['homebrew', 'MacOS']:
47
+ if hack in {y for x in sys.path for y in pathlib.Path(x).parts}:
48
+ FONTSIZE = 10
49
+ BASEFONT = 'System'
50
+ MOD = '\u2318' #CMD key unicode 2318 Place of Interest
51
+ CMDCTRL = 'Command' #tkinter bind string sans <>
52
+
53
+ if sg.running_windows():
54
+ TTK_THEME = 'vista'
55
+ FONTSIZE = 9
56
+ BASEFONT = 'Arial' #GRR #TODO no longer basefont on Windows
57
+ MOD = 'Ctrl-'
58
+ CMDCTRL = 'Control'
59
+
60
+ if sg.running_linux():
61
+ TTK_THEME = 'alt'
62
+ FONTSIZE = 9
63
+ BASEFONT = 'TkDefaultFont' #GRR
64
+ MOD = 'Ctrl-'
65
+ CMDCTRL = 'Control'
66
+ sg.set_options(font=f'{BASEFONT} {FONTSIZE}')
67
+
68
+ sg.theme('systemDefaultForReal')
69
+
70
+ # I dislike having this as a global variable
71
+ # TODO find a better way to store PREFDICT
72
+ PREFDICT = None
73
+
74
+ def debug_mode():
75
+ '''
76
+ Turn on debugger from the command line
77
+ '''
78
+ yes_to_debug = ['debug', 'd', '1']
79
+ if len(sys.argv) > 1:
80
+ debug = sys.argv[1]
81
+ if debug.lower() in yes_to_debug:
82
+ sg.show_debugger_window(location=(10,10))
83
+
84
+ def is_csv(indict:dict)->bool:
85
+ '''
86
+ Convenience function to see if output is csv
87
+
88
+ '''
89
+ #I really need to redo the whole works from scratch
90
+ if indict.get('out') == 'csv':
91
+ return True
92
+ return False
93
+
94
+ def pref_path()->pathlib.Path:
95
+ '''
96
+ Returns path to preferences directory and preferences
97
+ file name as a tuple
98
+ '''
99
+ if sg.running_mac():
100
+ return pathlib.Path(pathlib.Path('~/Library/Preferences').expanduser(),
101
+ 'ca.ubc.library.damage.prefs.plist')
102
+ if sg.running_windows():
103
+ return pathlib.Path(pathlib.Path('~/AppData/Local/damage').expanduser(), 'damage.json')
104
+ #Linux and everything else
105
+ return pathlib.Path(pathlib.Path('~/.config/damage').expanduser(),
106
+ 'damage.json')
107
+
108
+ def get_prefs()->None:
109
+ '''
110
+ Gets preferences from JSON or default dict. If no preferences
111
+ file is found, one is written
112
+ '''
113
+ global PREFDICT
114
+ try:
115
+ if sg.running_mac():
116
+ with open(pref_path(), 'rb') as fn:
117
+ PREFDICT = plistlib.load(fn)
118
+ if sg.running_linux() or sg.running_windows():
119
+ with open(pref_path(), encoding='utf-8') as fn:
120
+ PREFDICT = sg.json.load(fn)
121
+
122
+
123
+ except FileNotFoundError:
124
+ PREFDICT = { 'flatfile' :False,
125
+ 'recurse' :False,
126
+ 'digest' :'md5',
127
+ 'out' :'txt',
128
+ 'short' :True,
129
+ 'headers' :True,
130
+ 'nonascii' :True,
131
+ 'hidden' : False}
132
+
133
+ fixflat = PREFDICT.get('flat')
134
+ if fixflat:
135
+ PREFDICT['flatfile'] = fixflat
136
+ del PREFDICT['flat']
137
+ # Add new keys here when expanding prefs
138
+ PREFDICT['hidden'] = PREFDICT.get('hidden', False)
139
+
140
+ def set_prefs()->None:
141
+ '''
142
+ Sets preferences
143
+ '''
144
+ if not pref_path().parent.exists():
145
+ os.makedirs(pref_path().parent)
146
+ if sg.running_mac():
147
+ with open(pref_path(), 'wb') as fn:
148
+ plistlib.dump(PREFDICT, fn)
149
+ if sg.running_linux() or sg.running_windows():
150
+ with open(pref_path(), 'w', encoding='utf-8') as fn:
151
+ sg.json.dump(PREFDICT, fn)
152
+
153
+ def damager(flist, **kwargs)->str:
154
+ '''
155
+ Text output from Damage utility
156
+ '''
157
+ output = []
158
+ for num, fil in enumerate(flist):
159
+ if not pathlib.Path(fil).is_file() or not pathlib.Path(fil).exists():
160
+ continue
161
+ testme = damage.Checker(fil)
162
+ if kwargs['out'] == 'csv' and num == 0:
163
+ kwargs['headers'] = True
164
+ output.append(testme.manifest(**kwargs))
165
+ else:
166
+ kwargs['headers'] = False
167
+ output.append(testme.manifest(**kwargs))
168
+ if kwargs['out'] =='txt':
169
+ return '\n\n'.join(output)
170
+ if kwargs['out'] == 'csv':
171
+ return ''.join(output)#Horrible hack; excel dialiect automatically adds \r\n
172
+ outjson = ('{"files" :' +
173
+ '[' + ','.join(output) + ']'
174
+ + '}')
175
+ outjson = json.dumps(json.loads(outjson)) #validate
176
+ return outjson
177
+
178
+ def get_folder_files(direc:str, recursive:bool=False, hidden:bool=False)->list:
179
+ '''
180
+ Returns files in a folder, recursive or no
181
+ direc : str
182
+ path to directory
183
+ recursive : bool
184
+ Return a recursive result. Default False
185
+ hidden : bool
186
+ Show hidden files. Default False
187
+ '''
188
+ #HACKME
189
+ if not hidden:
190
+ hidden = PREFDICT.get('hidden', False)
191
+ if not direc:#Possible if window call cancelled, as I have discovered
192
+ #return None
193
+ return []
194
+ walker = pathlib.Path(direc).walk()
195
+ if not recursive:
196
+ walker = [next(walker)]
197
+ else:
198
+ walker = list(walker)
199
+ flist=[]
200
+ #Hidden is a property in the Checker object, but this comes before instantiation
201
+ if hidden:
202
+ #flist = [[x[0], pathlib.Path(x[0], y).name] for x in walker for y in x[2]
203
+ # if pathlib.Path(x[0],y).is_file()]
204
+ flist = [pathlib.Path(x[0], y) for x in walker for y in x[2]
205
+ if pathlib.Path(x[0],y).is_file()]
206
+
207
+ else:
208
+ #hidden defined as any part of the path starts with '.'
209
+ flist = [pathlib.Path(x[0],y) for x in walker for y in x[2]
210
+ if not any(z.startswith('.') for z in
211
+ pathlib.Path(x[0], y).parts)]
212
+ return flist
213
+
214
+ def send_to_file(outstring)->None:
215
+ '''
216
+ Sends string output to file
217
+
218
+ Creates a tk.asksaveasfile dialogue and saves
219
+ '''
220
+ #Because TK is just easier
221
+ outfile = sg.tk.filedialog.asksaveasfile(title='Save Output',
222
+ initialfile=f'output.{PREFDICT["out"]}',
223
+ confirmoverwrite=True)
224
+ if outfile:
225
+ outfile.write(outstring)
226
+ outfile.close()
227
+
228
+ def send_to_printer(outstring:str)->None:
229
+ '''
230
+ Sends output to lpr on Mac/linux and to default printer in Windows
231
+ Data is unformatted. If you want formatting save to a file and use
232
+ a text editor. Assumes UTF-8 for Mac/linux.
233
+ '''
234
+ #https://stackoverflow.com/questions/12723818/print-to-standard-printer-from-python
235
+ #outfile = tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
236
+ # suffix='.txt', delete=False)
237
+ #outfile.write(outstring)
238
+ #outfile.close()
239
+ with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8',
240
+ suffix='.txt', delete=False) as outfile:
241
+ outfile.write(outstring)
242
+
243
+ if sg.running_mac() or sg.running_linux():
244
+ #lpr = subprocess.Popen(shutil.which('lpr'), stdin=subprocess.PIPE)
245
+ #lpr.stdin.write(bytes(outstring, 'utf-8'))
246
+ subprocess.run([shutil.which('lpr'), outfile.name], check=False)
247
+
248
+ if sg.running_windows():
249
+
250
+ #List of all printers names and shows default one
251
+ #wmic printer get name,default
252
+ #https://stackoverflow.com/questions/13311201/get-default-printer-name-from-command-line
253
+ subout = subprocess.run(shlex.split('wmic printer get name,default'),
254
+ capture_output=True,
255
+ check=False)
256
+ #the following only makes sense of you look at the output of the
257
+ #windows shell command above. stout is binary, hence decode.
258
+ printerinfo = [[x[:6].strip(), x[6:].strip()] for x in
259
+ subout.stdout.decode().split('\n')[1:]]
260
+ default_printer = [x for x in printerinfo if x[0] == 'TRUE'][0][1]
261
+ subprocess.run(['print', f'/D:{default_printer}', outfile.name], check=False)
262
+ #tempfile must be removed manually because of delete=False above
263
+ os.remove(outfile.name)
264
+ sg.popup('Output sent to default printer', title='Job Completed', any_key_closes=True)
265
+
266
+ def about_window()->sg.Window:
267
+ '''
268
+ Creates the "About" window
269
+ '''
270
+ about = { 'developers' : ['Paul Lesack'],
271
+ 'user_testers' : ['Alex Alisauskas','Jeremy Buhler', 'Cheryl Niamath'],
272
+ 'source_url' : 'https://github.com/ubc-library-rc/damage',
273
+ 'documentation' : 'https://ubc-library-rc.github.io/damage'
274
+ }
275
+ #displayname = f'{PROGNAME[:PROGNAME.find("_")].capitalize()} v{__version__}'
276
+ displayname = f'{PROGNAME} v{__version__}'
277
+ name = [[sg.Text(displayname, font=f'{BASEFONT} {FONTSIZE+4} bold')]]
278
+ source =[[sg.Text('Source code', font=f'{BASEFONT} {FONTSIZE+2} bold')],
279
+ [sg.Text(about['source_url'], enable_events=True, text_color='blue', k='-SC-')]]
280
+ documentation =[[sg.Text('Documentation', font=f'{BASEFONT} {FONTSIZE+2} bold')],
281
+ [sg.Text(about['source_url'], enable_events=True,
282
+ text_color='blue', k='-DOC-')]]
283
+ devs =[[sg.Text('Developers', font=f'{BASEFONT} {FONTSIZE+2} bold',)],
284
+ [sg.Text(x) for x in about['developers']]]
285
+ testers = [[sg.Text('Testers', font=f'{BASEFONT} {FONTSIZE+2} bold')],
286
+ [sg.Text(x) for x in about['user_testers']]]
287
+ licence =[[sg.Text('Licence information', font=f'{BASEFONT} {FONTSIZE+2} bold')],
288
+ [sg.Text(LICENCE, font=f'{BASEFONT} {FONTSIZE-2}')]]
289
+ layout = name + documentation + devs + testers + source + licence
290
+ window = sg.Window('Credits & Details', modal=True,
291
+ icon=ICON,
292
+ keep_on_top=True,
293
+ layout=layout,
294
+ finalize=True)
295
+ while True:
296
+ #event, values = window.read()
297
+ # don't need values
298
+ event = window.read()[0]
299
+ if event == '-SC-':
300
+ webbrowser.open(about['source_url'])
301
+ if event == '-DOC-':
302
+ webbrowser.open(about['documentation'])
303
+ if event == sg.WIN_CLOSED:
304
+ break
305
+ window.close()
306
+
307
+ def prefs_window()->sg.Window:
308
+ '''
309
+ Creates a preferences popup window.
310
+ Values from window saved to the preferences dictionary PREFDICT
311
+ '''
312
+ #All the options
313
+ hashes =['md5','sha1', 'sha224', 'sha256', 'sha384', 'sha512',
314
+ 'blake2b', 'blake2s']
315
+ outputs = ['txt','csv', 'json']
316
+ rectang = ('Text file rectangularity '
317
+ '& statistics file column check' )
318
+ layout = [[sg.Text('Damage Preferences', font=f'{BASEFONT} {FONTSIZE+4} bold')],
319
+ [sg.Checkbox('Shorten file paths in output',
320
+ key= '-SHORT-',
321
+ default=PREFDICT['short'], )],
322
+ [sg.Checkbox(text=rectang,
323
+ key= '-FLATFILE-',
324
+ default=PREFDICT['flatfile'], )],
325
+ [sg.Checkbox('Recursively add files from directories',
326
+ key='-RECURSE-', default=PREFDICT['recurse'])],
327
+ [sg.Checkbox('Include hidden files',
328
+ key='-HIDDEN-', default=PREFDICT['hidden'])],
329
+ [sg.Text('Hash type'),
330
+ sg.Combo(values=hashes, default_value=PREFDICT['digest'],
331
+ key='-DIGEST-', readonly=True)],
332
+ [sg.Text('Output format'),
333
+ sg.Combo(values=outputs, default_value=PREFDICT['out'],
334
+ key='-OUT-', readonly=True)],
335
+ [sg.Ok(bind_return_key=True, button_text='OK')]]
336
+ pwindow = sg.Window(title='Preferences',
337
+ icon=ICON,
338
+ resizable=True,
339
+ layout=layout,
340
+ ttk_theme=TTK_THEME,
341
+ use_ttk_buttons=True,
342
+ keep_on_top=True,
343
+ modal=True, finalize=True)
344
+ pwindow.bind('<Escape>', 'Exit')
345
+ pevent, pvalues = pwindow.read()
346
+ if pevent:
347
+ for key in ['short', 'flatfile', 'recurse', 'digest', 'out', 'hidden']:
348
+ PREFDICT[key] = pvalues[f'-{key.upper()}-']
349
+ set_prefs()
350
+ if pevent == 'Exit':
351
+ pwindow.close()
352
+ pwindow.close()
353
+
354
+ def window_binds(window:sg.Window)->None:
355
+ '''
356
+ Bind keys to main window
357
+ '''
358
+ window.bind(f'<{CMDCTRL}-v>', '-PASTE-')
359
+ window.bind(f'<{CMDCTRL}-s>', '-SAVE-')
360
+ window.bind(f'<{CMDCTRL}-p>', '-PRINT-')
361
+ window.bind(f'<{CMDCTRL}-m>', '-MANIFEST-')
362
+
363
+ def main_window()->sg.Window:
364
+ '''
365
+ The main damage window
366
+ '''
367
+ #def get_folder_items()->list:
368
+ # '''
369
+ # Get all items from a folder listing
370
+ # '''
371
+ #I gave up attaching preferences to the mac menu
372
+ #because there's too much hidden using sg.
373
+ #You may as well use straight tk if you want that.
374
+
375
+ #Menu section
376
+ debug_mode()
377
+ menu = sg.Menu([['File',['Add &Files',
378
+ 'Add Fol&der',
379
+ 'Remove Files::-DELETE-']],
380
+ ['Edit', [f'&Copy {MOD}C::-COPY-',
381
+ f'&Paste {MOD}V::-PASTE-',
382
+ 'Preferences']],
383
+ ['Actions', [f'Create &Manifest {MOD}M::-MANIFEST-',
384
+ '---',
385
+ f'!&Save Output to File {MOD}S::-SAVE-',
386
+ f'!&Print Output {MOD}P::-PRINT-']],
387
+ ['Help', ['Damage Help', 'Credits and Details']]],
388
+ key='-MENUBAR-')
389
+ #Chosen file (left) section
390
+ lbox = sg.Listbox(values=[], key='-SELECT-',
391
+ enable_events=True,
392
+ size=20,
393
+ select_mode=sg.LISTBOX_SELECT_MODE_EXTENDED,
394
+ horizontal_scroll=True,
395
+ expand_y=True,
396
+ expand_x=True)
397
+ left = [[lbox],
398
+ [sg.Input(visible=False, enable_events=True, key='-IN-'),
399
+ sg.FilesBrowse(button_text='Add Files', target='-IN-'),
400
+ sg.Input(visible=False, enable_events=True, key='-IFOLD-'),
401
+ sg.FolderBrowse(button_text='Add Folder', key='-FOLDER-',
402
+ target='-IFOLD-',
403
+ initial_folder=pathlib.Path('~').expanduser()),
404
+ sg.Button(button_text='Remove Files',
405
+ enable_events=True, key='-DELETE-')]]
406
+
407
+ #Right side (output) section
408
+ #This is more complicated than it should be because psg doesn't
409
+ #flip correctly between Multiline and Table elements and doesn't hide
410
+ #the Table scrollbars, and resizing is also affected. The (a) solution
411
+ #is to put each of them in a Frame.
412
+ #But the best solution would be to use straight tkinter but it's too
413
+ #late for that, isn't it.
414
+
415
+ outbox = sg.Multiline(key='-OUTPUT-',
416
+ size=10000, #Auto-expansion has a bug
417
+ expand_x=True,
418
+ expand_y=True,
419
+ visible=True,
420
+ auto_refresh=True)
421
+ frame_1=sg.Frame(title ='',
422
+ layout=[[outbox]],
423
+ expand_x=True,
424
+ expand_y=True,
425
+ border_width=0,
426
+ key='F1',
427
+ visible=not is_csv(PREFDICT))
428
+
429
+ #CSV/tabular output box
430
+ #hardcoding the headers seems dumb,
431
+ #but the headers have to be read *after* instantiation of a Checker object
432
+ #TODONE used these ones for now, not really happy about it
433
+ headers = ['filename', 'digestType', 'digest',
434
+ 'min_cols', 'max_cols', 'numrec',
435
+ 'constant', 'encoding', 'nonascii',
436
+ 'null_chars', 'mimetype', 'dos']
437
+
438
+ #right side layout
439
+ csvout = sg.Table([['' for x in range(len(headers))]], #Need data to create the table
440
+ headings=headers,
441
+ key='-CSV-',
442
+ #num_rows=40, #not needed with expansion
443
+ alternating_row_color='#FFA805',
444
+ #size=(800,40),
445
+ expand_x=True,
446
+ expand_y=True,
447
+ hide_vertical_scroll=False,
448
+ vertical_scroll_only=False,
449
+ auto_size_columns=True,
450
+ #def_col_width=200,#If this is at default window expansion doesn't work
451
+ #max_col_width=2000,#This ought to be enough
452
+ #visible=is_csv(PREFDICT),
453
+ visible=True,
454
+ enable_events=True
455
+ )
456
+ frame_2=sg.Frame(title ='',
457
+ layout=[[csvout]],
458
+ expand_x=True,
459
+ expand_y=True,
460
+ border_width=0,
461
+ key='F2',
462
+ visible=True)
463
+
464
+
465
+ right = [[frame_1, frame_2],
466
+ [sg.Text(expand_x=True),
467
+ sg.Button('Generate Manifest',
468
+ key='-MANIFEST-') ]]
469
+
470
+ #Main window layout using all the elements above
471
+ layout = [[menu],
472
+ [sg.Frame(title='File Section',
473
+ layout=left,
474
+ size=(400,800),
475
+ expand_y=True,
476
+ expand_x=True),
477
+ sg.Frame(title='Output Section',
478
+ size=(800,800),
479
+ layout=right,
480
+ expand_y=True,
481
+ expand_x=True)]]
482
+
483
+ window = sg.Window(title='Damage', layout=layout, resizable=True,
484
+ size = PREFDICT.get('main_size', (None, None)),
485
+ icon=ICON,
486
+ ttk_theme=TTK_THEME,
487
+ use_ttk_buttons=True,
488
+ location= PREFDICT.get('main_location', (None, None)),
489
+ finalize=True, enable_close_attempted_event=True)
490
+ lbox.Widget.config(borderwidth=0)
491
+ window.set_min_size((875,400))
492
+ window_binds(window)
493
+ return window
494
+
495
+ def main()->None:
496
+ '''
497
+ Main loop
498
+ '''
499
+ get_prefs()
500
+ window = main_window()
501
+ #FFFFUUUUU
502
+ menulayout = window['-MENUBAR-'].MenuDefinition
503
+ #sg.easy_print(menulayout)
504
+ #Why don't I just do it all in TK? Jesus
505
+ #talk about undocumented.
506
+ #also: https://tkdocs.com/tutorial/menus.html
507
+ #window.TKroot.tk.createcommand('tk::mac::ShowPreferences', prefs_window )
508
+ # How to get root? What is the function where None is?
509
+ #root = window.hidden_master_root #(see PySimpleGUI.py.StartupTK, line 16008)
510
+ #sg.easy_print(type(poot))
511
+ #root.createcommand('tk::mac::ShowPreferences', prefs_window )
512
+ #root.createcommand('tk::mac::ShowPreferences', lambda: None)
513
+ #Also, why does the window stop responding after calling the prefs? But only half? It's
514
+ #a fucking mystery. And it doesn't happen with straight TK so it's something to with psg.
515
+ #root.createcommand('tk::mac::standardAboutPanel', about_window)
516
+ while True:
517
+ event, values = window.read()
518
+
519
+ if event in (sg.WINDOW_CLOSE_ATTEMPTED_EVENT,):
520
+ PREFDICT['main_size'] = window.size
521
+ PREFDICT['main_location'] = window.current_location()
522
+ set_prefs()
523
+ break
524
+
525
+ if event in (sg.WINDOW_CLOSED,):
526
+ break
527
+
528
+ if event == '-IN-':
529
+ #sg.easy_print(f"Values=| {values['-IN-']} |", c='white on red')
530
+ upd_list = (window['-SELECT-'].get_list_values() +
531
+ [x for x in values['-IN-'].split(';') if
532
+ x not in window['-SELECT-'].get_list_values()])
533
+ if PREFDICT.get('hidden', False):
534
+ upd_list = [x for x in upd_list if pathlib.Path(x).is_file()]
535
+ else:
536
+ upd_list = [x for x in upd_list if pathlib.Path(x).is_file()
537
+ and not any(z.startswith('.')
538
+ for z in pathlib.Path(x).parts)]
539
+ window['-SELECT-'].update(upd_list)
540
+ window['-IN-'].update(value='')
541
+
542
+ if event in ('-IFOLD-', 'Add Folder'):
543
+ if event in ('Add Folder',):
544
+ #Not implementing same behaviour in button
545
+ #requires rewriting menu
546
+ #TODO find a way to have the menu item
547
+ #and button have the behaviour below
548
+ #probably by having the button replaced by a function call.
549
+ initiald = pathlib.Path('~').expanduser()
550
+ newfiles = get_folder_files(sg.popup_get_folder('',
551
+ no_window=True,
552
+ initial_folder=initiald),
553
+ PREFDICT['recurse'])
554
+ else:
555
+ newfiles = get_folder_files(values['-IFOLD-'], PREFDICT['recurse'])
556
+ upd_list = window['-SELECT-'].get_list_values()
557
+ #sg.easy_print('Used Add Folder Button')
558
+ upd_list = upd_list+ [x for x in newfiles if x not in upd_list]
559
+ window['-SELECT-'].update(upd_list)
560
+
561
+ if event.endswith('-DELETE-'):
562
+ nlist = [x for x in window['-SELECT-'].get_list_values() if
563
+ x not in values['-SELECT-']]
564
+ window['-SELECT-'].update(nlist)
565
+
566
+ if event.endswith('-MANIFEST-'):
567
+ #sg.easy_print(event, values)
568
+ delme = ''
569
+ #Only clear manifest
570
+ window['-OUTPUT-'].update(delme)#clear first
571
+ window['-CSV-'].update(delme)#clear first
572
+ try:
573
+ upd_list = window['-SELECT-'].get_list_values()
574
+ if upd_list == ['']: #why
575
+ upd_list = []
576
+ if upd_list:
577
+ txt = damager(upd_list, **PREFDICT)
578
+ if PREFDICT['short'] and len(upd_list) > 1:
579
+ #commonpath does not include last sep, very annoying
580
+ delme = os.path.commonpath(upd_list) + os.sep
581
+ window['-OUTPUT-'].update(txt.replace(delme,''))
582
+ elif PREFDICT['short'] and len(upd_list) == 1:
583
+ #delme = os.path.split(upd_list[0])[0] + os.sep
584
+ delme = pathlib.Path(upd_list[0]).parts[0] + os.sep #consistency
585
+ txt = txt.replace(delme,'')
586
+ window['-OUTPUT-'].update(txt)
587
+
588
+ if PREFDICT.get('out') == 'csv':
589
+ txt = txt.split('\n')
590
+ #reader=csv.reader(txt[1::2], delimiter=',')#Strip out the headers
591
+ #window['-CSV-'].update(values=list(reader))
592
+ #nout = [txt[0]] + txt[1::2]#combine header with data
593
+ ##and send to output window for printing/saving
594
+ #window['-OUTPUT-'].update('\n'.join(nout))
595
+
596
+ #new
597
+ #This is like it is because window['-CSV-'] takes lists
598
+ #as values and window['-OUTPUT-] is text output
599
+ #and csv.Reader adds windows \r\n.
600
+ nout = [x.strip().split(',') for x in txt[1:]]
601
+ nout = [x for x in nout if x !=['']]
602
+ window['-CSV-'].update(nout)
603
+ nout = [txt[0].strip()]+[','.join(z) for z in nout]
604
+ nout = '\n'.join(nout)
605
+ window['-OUTPUT-'].update(nout)
606
+
607
+
608
+
609
+ except (ValueError, NameError, AttributeError):
610
+ window['-OUTPUT-'].update(delme)
611
+
612
+ if event == 'Preferences':
613
+ #sg.easy_print(values)
614
+ prefs_window()
615
+ #Show correct window pane for output.
616
+ xpandr = is_csv(PREFDICT)
617
+ window['F1'].update(visible=not xpandr)
618
+ window['F2'].update(visible=xpandr)
619
+ window['F1'].expand(expand_x=not xpandr, expand_y=not xpandr)
620
+ window['F2'].expand(expand_x=xpandr, expand_y=xpandr)
621
+ window['-OUTPUT-'].update('')
622
+ #sg.easy_print(PREFDICT)
623
+ window.refresh()
624
+
625
+ if window['-OUTPUT-'].get():
626
+ #update menu. This is a PIA.
627
+ menulayout[2][1][2] = f'&Save Output to File {MOD}S::-SAVE-'
628
+ menulayout[2][1][3] = f'&Print Output {MOD}P::-PRINT-'
629
+ window['-MENUBAR-'].update(menulayout)
630
+
631
+ if event.endswith('-SAVE-'):
632
+ send_to_file(values['-OUTPUT-'])
633
+
634
+ if event.endswith('-PRINT-'):
635
+ send_to_printer(values['-OUTPUT-'])
636
+ else:
637
+ menulayout[2][1][2] = f'!&Save Output to File {MOD}S::-SAVE-'
638
+ menulayout[2][1][3] = f'!&Print Output {MOD}P::-PRINT-'
639
+ window['-MENUBAR-'].update(menulayout)
640
+
641
+ #Menubar events
642
+ if event == 'Add Files':
643
+ newfiles = sg.popup_get_file(message='', no_window=True,
644
+ multiple_files=True,
645
+ file_types = sg.FILE_TYPES_ALL_FILES)
646
+
647
+ upd_list = (window['-SELECT-'].get_list_values() +
648
+ [x for x in newfiles if
649
+ x not in window['-SELECT-'].get_list_values()])
650
+ window['-SELECT-'].update(upd_list)
651
+
652
+ if event == 'Credits and Details':
653
+ about_window()
654
+ if event == 'Damage Help':
655
+ webbrowser.open('https://ubc-library-rc.github.io/damage')
656
+
657
+ #Copypasta
658
+ if event.endswith(':-COPY-'):
659
+ #sg.clipboard_get()
660
+ #Oh look more straight Tkinter.
661
+ try:
662
+ sg.clipboard_set(window['-OUTPUT-'].Widget.selection_get())
663
+ except sg.tkinter.TclError:
664
+ pass
665
+ #sg.easy_print('COPY!!!', c='white on blue')
666
+ #sg.easy_print(sg.clipboard_get())
667
+ if event.endswith(':-PASTE-'):#Menu only because pastes by default
668
+ try:
669
+ #sg.easy_print('PASTE!!!', c='white on orange')
670
+ window['-OUTPUT-'].Widget.insert(sg.tk.INSERT,
671
+ sg.clipboard_get())
672
+ except sg.tkinter.TclError:
673
+ pass
674
+
675
+ window.close()
676
+
677
+ if __name__ == '__main__':
678
+ main()
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright 2021 University of British Columbia Library
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.3
2
+ Name: damage
3
+ Version: 0.3.14
4
+ Summary: File manifest generator and python package for statistical data files and documentation'
5
+ License: MIT
6
+ Keywords: metadata,SAS,SPSS,Stata,rectangular files,manifest generator
7
+ Author: Paul Lesack
8
+ Author-email: paul.lesack@ubc.ca
9
+ Requires-Python: >=3.12, <4
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Environment :: MacOS X
13
+ Classifier: Environment :: Win32 (MS Windows)
14
+ Classifier: Environment :: X11 Applications
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Education
21
+ Classifier: Topic :: Utilities
22
+ Requires-Dist: chardet (>=5.2.0,<6.0.0)
23
+ Requires-Dist: freesimplegui (>=5.2.0,<6.0.0)
24
+ Requires-Dist: numpy (>=2.2.3,<3.0.0)
25
+ Requires-Dist: pandas (>=2.2.3,<3.0.0)
26
+ Requires-Dist: pyreadstat (>=1.2.8,<2.0.0)
27
+ Project-URL: Homepage, https://ubc-library-rc.github.io/damage
28
+ Project-URL: Issue Tracker, https://github.com/ubc-library-rc/damage/issues
29
+ Project-URL: Repository, https://github.com/ubc-library-rc/damage
30
+ Description-Content-Type: text/markdown
31
+
32
+ # File manifest tools: Damage
33
+
34
+ Damage is a simple command-line utility which outputs a file manifest in a variety of formats, with a special focus on statistical package files from SPSS, SAS and Stata. It's also the name of the Python package which you can use in your own code and which powers the _damage_ utility.
35
+
36
+ Source code and documentation files are available at <https://github.com/ubc-library-rc/damage>. Documentation is in the intuitively named _docs_ subdirectory.
37
+
38
+ Binary versions of the *damage* utility for Windows and MacOS computers can be found on the project's Github release page: <https://github.com/ubc-library-rc/damage/releases>.
39
+
40
+ A less utilitarian documentation viewing experience is available at <https://ubc-library-rc.github.io/damage/>.
41
+
@@ -0,0 +1,13 @@
1
+ damage/__init__.py,sha256=HTjQQenVmw0ilI8WHQb1mISvx-NWtUvicKX4xezMSWw,14872
2
+ damage/console/damage_cmd.py,sha256=5CPh_HpbDRSuUybxvGDLxAcW6Aec4HW5hJeMAsb5PU4,5079
3
+ damage/gui/assets/DamageAppIcon.icns,sha256=_ObyYd-c8D7zlQosQnOVqv12zteMzIykzzZkmkZg3Mk,91447
4
+ damage/gui/assets/DamageAppIcon.ico,sha256=tnf5VUkYXV27R34DhM0GqBXkbMoz2DwbCD9vM9vpzxM,622678
5
+ damage/gui/assets/DamageAppIcon.jpg,sha256=kBEFy52EUWlLY3HDphQSFd2wIUHAEbT750E5HassNUk,76307
6
+ damage/gui/assets/DamageAppIcon.png,sha256=E0HIRi8i7Isa3vCr7pT6fVXQ9qHgkzuOs_ZJ52HQTao,52486
7
+ damage/gui/assets/LICENCE.txt,sha256=W3g7rf2LtjFk4Fhp740RSuB_cHgVR5p2Gq61JyufvmI,1092
8
+ damage/gui/damage_gui.py,sha256=0X3dkfhSVpmFQZLhiNYP33jJ9WnUuebm3LkRR5-Mr-g,27198
9
+ damage-0.3.14.dist-info/LICENCE.md,sha256=oVoR07zgulhKIi0J7RA518_QhHqqfdq9p9B8aYCMVd8,1091
10
+ damage-0.3.14.dist-info/METADATA,sha256=T4YBN0J-dVFcEYyT3qoWZ_ZIyd7mNu93D5AOJkLa6yU,2045
11
+ damage-0.3.14.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
12
+ damage-0.3.14.dist-info/entry_points.txt,sha256=K-F6BbGVbvPIRvbQCCE-TZWDymgmGgFUmaQG5UP1QuI,148
13
+ damage-0.3.14.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.1.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,7 @@
1
+ [console_scripts]
2
+ damage=damage.console.damage_cmd:main
3
+ damage-gui=damage.gui.damage_gui:main
4
+
5
+ [gui_scripts]
6
+ damage-gui=damage.gui.damage_gui:main
7
+