Historie 0.1.0__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.
historie/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = '0.1.0'
historie/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == '__main__':
5
+ main()
historie/cli.py ADDED
@@ -0,0 +1,66 @@
1
+ import argparse
2
+ import fileinput
3
+ import logging
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from . import exporters
8
+
9
+
10
+ logger = logging.getLogger(__package__)
11
+
12
+
13
+ def build_parser():
14
+ parser = argparse.ArgumentParser(
15
+ prog='historie',
16
+ description=(
17
+ 'Generate a résumé in a publish format from reStructuredText.'
18
+ ),
19
+ )
20
+ parser.add_argument(
21
+ '--logging-level',
22
+ help='Logging level. By default: %(default)s.',
23
+ default='INFO',
24
+ )
25
+ parser.add_argument(
26
+ '-f', '--format',
27
+ choices=['html', 'pdf'],
28
+ default='html',
29
+ help='Output format. By default: %(default)s.',
30
+ )
31
+ parser.add_argument(
32
+ '-o', '--output',
33
+ type=Path,
34
+ help='Output file: By default ".rst" is appended to input.',
35
+ )
36
+ parser.add_argument('input', help='Input reStructuredText file')
37
+
38
+ return parser
39
+
40
+
41
+ def main():
42
+ logging.basicConfig(
43
+ level=logging.INFO,
44
+ format='%(asctime)s %(levelname)-7s %(name)s %(message)s',
45
+ datefmt='%Y-%m-%d %H:%M:%S',
46
+ )
47
+
48
+ args = build_parser().parse_args()
49
+ logging.root.setLevel(args.logging_level)
50
+
51
+ input_path = Path(args.input)
52
+ if args.input and args.input != '-' and not input_path.exists():
53
+ logger.error('Input file not found: %s', args.input)
54
+ sys.exit(1)
55
+
56
+ if args.output:
57
+ output = args.output
58
+ elif args.input and args.input != '-':
59
+ output = f'{input_path.stem}.{args.format}'
60
+ else:
61
+ output = f'output.{args.format}'
62
+
63
+ exporter = exporters.registry[args.format]
64
+ with fileinput.input(files=args.input) as f_in:
65
+ with open(output, 'wb') as f_out:
66
+ exporter(''.join(line for line in f_in), f_out)
historie/directives.py ADDED
@@ -0,0 +1,447 @@
1
+ from datetime import date
2
+ from urllib.parse import quote
3
+
4
+ from docutils import nodes
5
+ from docutils.parsers import rst
6
+ from docutils.parsers.rst.directives import register_directive, unchanged
7
+
8
+ from . import utility
9
+
10
+
11
+ __all__ = 'register_directives',
12
+
13
+
14
+ def register_directives():
15
+ directives = (
16
+ ContactDirective,
17
+ EducationDirective,
18
+ JobDirective,
19
+ LinkDirective,
20
+ ProjectDirective,
21
+ StackExchangeProfileDirective,
22
+ SummaryDirective,
23
+ TechnologyPreferenceDirective,
24
+ )
25
+ for d in directives:
26
+ register_directive(d.name, d)
27
+
28
+
29
+ def split_tags(s: str) -> list[str]:
30
+ return [t.strip() for t in s.split(',') if t.strip()]
31
+
32
+
33
+ def format_date_range(start: date | None, finish: date | None) -> str:
34
+ if start and finish:
35
+ return f'{start.strftime("%b %Y")} – {finish.strftime("%b %Y")}'
36
+ elif start:
37
+ return f'{start.strftime("%b %Y")} – present'
38
+ else:
39
+ return ''
40
+
41
+
42
+ class ContactDirective(rst.Directive):
43
+
44
+ name = 'his-contacts'
45
+
46
+ has_content = True
47
+ option_spec = {
48
+ 'name': unchanged,
49
+ 'title': unchanged,
50
+ 'location': unchanged,
51
+ 'email': unchanged,
52
+ }
53
+
54
+ def run(self):
55
+ container = nodes.container(classes=[self.name])
56
+ for k, v in self.options.items():
57
+ if k == 'email':
58
+ ref = nodes.reference('', v, refuri=f'mailto:{v}')
59
+ li = nodes.list_item('', nodes.inline('', '', ref), classes=[f'{self.name}-{k}'])
60
+ else:
61
+ li = nodes.list_item('', nodes.inline('', v), classes=[f'{self.name}-{k}'])
62
+ container.append(li)
63
+
64
+ if 'name' in self.options:
65
+ self.state.document.settings.title = self.options['name']
66
+
67
+ return [container]
68
+
69
+
70
+ class LinkDirective(rst.Directive):
71
+
72
+ name = 'his-links'
73
+
74
+ has_content = True
75
+
76
+ def run(self):
77
+ container = nodes.container(classes=[self.name])
78
+
79
+ if not self.content:
80
+ return [container]
81
+
82
+ node = nodes.Element()
83
+ self.state.nested_parse(self.content, self.content_offset, node)
84
+
85
+ items = []
86
+ for child in node:
87
+ if not isinstance(child, nodes.definition_list):
88
+ continue
89
+
90
+ for item in child:
91
+ if not isinstance(item, nodes.definition_list_item):
92
+ continue
93
+
94
+ term = item[0].astext() if item.children else ''
95
+ definition = item[1].astext().strip() if len(item) > 1 else ''
96
+ if term and definition:
97
+ ref = nodes.reference('', term, refuri=definition)
98
+ ref['classes'].append('his-link')
99
+ li = nodes.list_item('', nodes.inline('', '', ref))
100
+ items.append(li)
101
+
102
+ if items:
103
+ container.append(nodes.bullet_list('', *items))
104
+
105
+ return [container]
106
+
107
+
108
+ class StackExchangeProfileDirective(rst.Directive):
109
+
110
+ name = 'his-stack-exchange-profiles'
111
+
112
+ has_content = False
113
+ option_spec = {
114
+ 'stackoverflow': unchanged,
115
+ 'serverfault': unchanged,
116
+ 'superuser': unchanged,
117
+ 'askubuntu': unchanged,
118
+ 'unix': unchanged,
119
+ 'dba': unchanged,
120
+ 'softwareengineering': unchanged,
121
+ }
122
+
123
+ _site_urls = {
124
+ 'stackoverflow': 'https://stackoverflow.com/users/{}',
125
+ 'serverfault': 'https://serverfault.com/users/{}',
126
+ 'superuser': 'https://superuser.com/users/{}',
127
+ 'askubuntu': 'https://askubuntu.com/users/{}',
128
+ 'unix': 'https://unix.stackexchange.com/users/{}',
129
+ 'dba': 'https://dba.stackexchange.com/users/{}',
130
+ 'softwareengineering': 'https://softwareengineering.stackexchange.com/users/{}',
131
+ }
132
+ _site_names = {
133
+ 'stackoverflow': 'Stack Overflow',
134
+ 'serverfault': 'Server Fault',
135
+ 'superuser': 'Super User',
136
+ 'askubuntu': 'Ask Ubuntu',
137
+ 'unix': 'Unix & Linux',
138
+ 'dba': 'Database Administrators',
139
+ 'softwareengineering': 'Software Engineering',
140
+ }
141
+
142
+ def _create_header(
143
+ self, profile: utility.StackExchangeProfile | None, site: str, user_id: int
144
+ ) -> nodes.container:
145
+ header = nodes.container(classes=[f'{self.name}-header'])
146
+
147
+ site_name = self._site_names[site]
148
+ url = self._site_urls[site].format(user_id)
149
+
150
+ site_para = nodes.paragraph('', '', classes=[f'{self.name}-site'])
151
+ ref = nodes.reference('', site_name, refuri=url)
152
+ ref['classes'].append('his-link')
153
+ site_para.append(ref)
154
+ if profile:
155
+ if profile.answer_count > 0:
156
+ site_para.append(nodes.inline('', ' · '))
157
+ answers_ref = nodes.reference(
158
+ '', f'{profile.answer_count} answers', refuri=f'{url}?tab=answers'
159
+ )
160
+ answers_ref['classes'].append(f'{self.name}-badge')
161
+ site_para.append(answers_ref)
162
+
163
+ if profile.question_count > 0:
164
+ site_para.append(nodes.inline('', ' · '))
165
+ questions_ref = nodes.reference(
166
+ '', f'{profile.question_count} questions', refuri=f'{url}?tab=questions'
167
+ )
168
+ questions_ref['classes'].append(f'{self.name}-badge')
169
+ site_para.append(questions_ref)
170
+ header.append(site_para)
171
+
172
+ if profile:
173
+ date_range = format_date_range(profile.member_since.date(), None)
174
+ if date_range:
175
+ header.append(nodes.paragraph(
176
+ '', date_range, classes=[f'{self.name}-dates']
177
+ ))
178
+
179
+ return header
180
+
181
+ def _create_top_tags(self, profile: utility.StackExchangeProfile) -> nodes.container:
182
+ tag_container = nodes.container(classes=[f'{self.name}-tags'])
183
+ for tag in profile.top_tags[:5]:
184
+ uri = 'https://stackoverflow.com/tags/{}'.format(quote(tag))
185
+ ref = nodes.reference('', tag, refuri=uri)
186
+ ref['classes'].append('his-tag')
187
+ tag_container.append(ref)
188
+
189
+ return tag_container
190
+
191
+ def run(self):
192
+ container = nodes.container(classes=[self.name])
193
+
194
+ for site, user_id in self.options.items():
195
+ if not user_id or site not in self._site_urls:
196
+ continue
197
+
198
+ profile = utility.fetch_stack_exchange_profile(site, user_id)
199
+
200
+ item = nodes.container(classes=[f'{self.name}-item', f'{self.name}-{site}'])
201
+ item.append(self._create_header(profile, site, user_id))
202
+ if profile and profile.top_tags:
203
+ item.append(self._create_top_tags(profile))
204
+ container.append(item)
205
+
206
+ return [container]
207
+
208
+
209
+ class TechnologyPreferenceDirective(rst.Directive):
210
+
211
+ name = 'his-technology-preference'
212
+
213
+ has_content = False
214
+ option_spec = {
215
+ 'desired': split_tags,
216
+ 'undesired': split_tags,
217
+ }
218
+
219
+ def _create_tag_item(self, tag: str) -> nodes.list_item:
220
+ uri = 'https://stackoverflow.com/tags/{}'.format(quote(tag))
221
+ ref = nodes.reference('', tag, refuri=uri)
222
+ ref['classes'].append('his-tag')
223
+ return nodes.list_item('', nodes.inline('', '', ref))
224
+
225
+ def run(self):
226
+ desired: list[str] = self.options.get('desired', [])
227
+ undesired: list[str] = self.options.get('undesired', [])
228
+
229
+ container = nodes.container(classes=[self.name])
230
+ if desired:
231
+ section = nodes.container(classes=[f'{self.name}-desired'])
232
+ section.append(nodes.paragraph('', 'I want to work with'))
233
+ section.append(nodes.bullet_list('', *map(self._create_tag_item, desired)))
234
+ container.append(section)
235
+ if undesired:
236
+ section = nodes.container(classes=[f'{self.name}-undesired'])
237
+ section.append(nodes.paragraph('', 'I prefer not to work with'))
238
+ section.append(nodes.bullet_list('', *map(self._create_tag_item, undesired)))
239
+ container.append(section)
240
+
241
+ return [container]
242
+
243
+
244
+ class SummaryDirective(rst.Directive):
245
+
246
+ name = 'his-summary'
247
+
248
+ has_content = True
249
+
250
+ def run(self):
251
+ self.assert_has_content()
252
+ container = nodes.container(classes=[self.name])
253
+ self.state.nested_parse(self.content, self.content_offset, container)
254
+ return [container]
255
+
256
+
257
+ class JobDirective(rst.Directive):
258
+
259
+ name = 'his-job'
260
+ section_title = 'Experience'
261
+
262
+ has_content = True
263
+ option_spec = {
264
+ 'company-name': unchanged,
265
+ 'company-website': unchanged,
266
+ 'role': unchanged,
267
+ 'start': date.fromisoformat,
268
+ 'finish': date.fromisoformat,
269
+ 'technology': split_tags,
270
+ }
271
+
272
+ def run(self):
273
+ company_name: str = self.options.get('company-name', '')
274
+ company_website: str | None = self.options.get('company-website')
275
+ role: str = self.options.get('role', '')
276
+ start: date | None = self.options.get('start')
277
+ finish: date | None = self.options.get('finish')
278
+ technology: list[str] = self.options.get('technology', [])
279
+
280
+ result = []
281
+ if not getattr(self.state.document, '_his_job_seen', False):
282
+ self.state.document._his_job_seen = True
283
+ title = nodes.paragraph('', self.section_title, classes=['his-section-title'])
284
+ result.append(title)
285
+
286
+ container = nodes.container(classes=[self.name])
287
+
288
+ header = nodes.container(classes=[f'{self.name}-header'])
289
+ if company_website:
290
+ company_ref = nodes.reference('', company_name, refuri=company_website)
291
+ company_ref['classes'].append('his-link')
292
+ header.append(nodes.paragraph('', '', company_ref, classes=[f'{self.name}-company']))
293
+ else:
294
+ header.append(nodes.paragraph('', company_name, classes=[f'{self.name}-company']))
295
+
296
+ if role:
297
+ header.append(nodes.paragraph('', role, classes=[f'{self.name}-role']))
298
+
299
+ date_range = format_date_range(start, finish)
300
+ if date_range:
301
+ header.append(nodes.paragraph('', date_range, classes=[f'{self.name}-dates']))
302
+
303
+ container.append(header)
304
+
305
+ if technology:
306
+ tech_container = nodes.container(classes=[f'{self.name}-technology'])
307
+ for tag in technology:
308
+ uri = 'https://stackoverflow.com/tags/{}'.format(quote(tag))
309
+ ref = nodes.reference('', tag, refuri=uri)
310
+ ref['classes'].append('his-tag')
311
+ tech_container.append(ref)
312
+ container.append(tech_container)
313
+
314
+ if self.content:
315
+ description = nodes.container(classes=[f'{self.name}-description'])
316
+ self.state.nested_parse(self.content, self.content_offset, description)
317
+ container.append(description)
318
+
319
+ result.append(container)
320
+ return result
321
+
322
+
323
+ class EducationDirective(rst.Directive):
324
+
325
+ name = 'his-education'
326
+ section_title = 'Education'
327
+
328
+ has_content = True
329
+ option_spec = {
330
+ 'institution': unchanged,
331
+ 'institution-website': unchanged,
332
+ 'degree': unchanged,
333
+ 'start': date.fromisoformat,
334
+ 'finish': date.fromisoformat,
335
+ 'technology': split_tags,
336
+ }
337
+
338
+ def run(self):
339
+ institution: str = self.options.get('institution', '')
340
+ institution_website: str | None = self.options.get('institution-website')
341
+ degree: str = self.options.get('degree', '')
342
+ start: date | None = self.options.get('start')
343
+ finish: date | None = self.options.get('finish')
344
+ technology: list[str] = self.options.get('technology', [])
345
+
346
+ result = []
347
+ if not getattr(self.state.document, '_his_education_seen', False):
348
+ self.state.document._his_education_seen = True
349
+ title = nodes.paragraph('', self.section_title, classes=['his-section-title'])
350
+ result.append(title)
351
+
352
+ container = nodes.container(classes=[self.name])
353
+
354
+ header = nodes.container(classes=[f'{self.name}-header'])
355
+ if institution_website:
356
+ ref = nodes.reference('', institution, refuri=institution_website)
357
+ ref['classes'].append('his-link')
358
+ header.append(nodes.paragraph('', '', ref, classes=[f'{self.name}-institution']))
359
+ else:
360
+ header.append(nodes.paragraph('', institution, classes=[f'{self.name}-institution']))
361
+
362
+ if degree:
363
+ header.append(nodes.paragraph('', degree, classes=[f'{self.name}-degree']))
364
+
365
+ date_range = format_date_range(start, finish)
366
+ if date_range:
367
+ header.append(nodes.paragraph('', date_range, classes=[f'{self.name}-dates']))
368
+
369
+ container.append(header)
370
+
371
+ if technology:
372
+ tech_container = nodes.container(classes=[f'{self.name}-technology'])
373
+ for tag in technology:
374
+ uri = 'https://stackoverflow.com/tags/{}'.format(quote(tag))
375
+ ref = nodes.reference('', tag, refuri=uri)
376
+ ref['classes'].append('his-tag')
377
+ tech_container.append(ref)
378
+ container.append(tech_container)
379
+
380
+ if self.content:
381
+ description = nodes.container(classes=[f'{self.name}-description'])
382
+ self.state.nested_parse(self.content, self.content_offset, description)
383
+ container.append(description)
384
+
385
+ result.append(container)
386
+ return result
387
+
388
+
389
+ class ProjectDirective(rst.Directive):
390
+
391
+ name = 'his-project'
392
+ section_title = 'Projects'
393
+
394
+ has_content = True
395
+ option_spec = {
396
+ 'name': unchanged,
397
+ 'url': unchanged,
398
+ 'start': date.fromisoformat,
399
+ 'finish': date.fromisoformat,
400
+ 'technology': split_tags,
401
+ }
402
+
403
+ def run(self):
404
+ name: str = self.options.get('name', '')
405
+ url: str | None = self.options.get('url')
406
+ start: date | None = self.options.get('start')
407
+ finish: date | None = self.options.get('finish')
408
+ technology: list[str] = self.options.get('technology', [])
409
+
410
+ result = []
411
+ if not getattr(self.state.document, '_his_project_seen', False):
412
+ self.state.document._his_project_seen = True
413
+ title = nodes.paragraph('', self.section_title, classes=['his-section-title'])
414
+ result.append(title)
415
+
416
+ container = nodes.container(classes=[self.name])
417
+
418
+ header = nodes.container(classes=[f'{self.name}-header'])
419
+ if url:
420
+ ref = nodes.reference('', name, refuri=url)
421
+ ref['classes'].append('his-link')
422
+ header.append(nodes.paragraph('', '', ref, classes=[f'{self.name}-name']))
423
+ else:
424
+ header.append(nodes.paragraph('', name, classes=[f'{self.name}-name']))
425
+
426
+ date_range = format_date_range(start, finish)
427
+ if date_range:
428
+ header.append(nodes.paragraph('', date_range, classes=[f'{self.name}-dates']))
429
+
430
+ container.append(header)
431
+
432
+ if technology:
433
+ tech_container = nodes.container(classes=[f'{self.name}-technology'])
434
+ for tag in technology:
435
+ uri = 'https://stackoverflow.com/tags/{}'.format(quote(tag))
436
+ ref = nodes.reference('', tag, refuri=uri)
437
+ ref['classes'].append('his-tag')
438
+ tech_container.append(ref)
439
+ container.append(tech_container)
440
+
441
+ if self.content:
442
+ description = nodes.container(classes=[f'{self.name}-description'])
443
+ self.state.nested_parse(self.content, self.content_offset, description)
444
+ container.append(description)
445
+
446
+ result.append(container)
447
+ return result
historie/exporters.py ADDED
@@ -0,0 +1,32 @@
1
+ import io
2
+ from typing import BinaryIO
3
+
4
+ from docutils.core import publish_string
5
+
6
+ from .directives import register_directives
7
+ from .html_writer import HistorieHtmlWriter
8
+
9
+
10
+ __all__ = 'registry',
11
+
12
+
13
+ def export_html(source_rst: str, output_file: BinaryIO):
14
+ register_directives()
15
+ writer = HistorieHtmlWriter()
16
+ html = publish_string(source_rst, writer=writer)
17
+ output_file.write(html)
18
+
19
+
20
+ def export_pdf(source_rst: str, output_file: BinaryIO):
21
+ import weasyprint
22
+
23
+ buffer = io.BytesIO()
24
+ export_html(source_rst, buffer)
25
+
26
+ weasyprint.HTML(file_obj=buffer).write_pdf(output_file)
27
+
28
+
29
+ registry = {
30
+ 'html': export_html,
31
+ 'pdf': export_pdf,
32
+ }
@@ -0,0 +1,61 @@
1
+ from pathlib import Path
2
+
3
+ from docutils import nodes
4
+ from docutils.writers import html5_polyglot
5
+
6
+ from . import utility
7
+
8
+
9
+ __all__ = 'HistorieHtmlWriter',
10
+
11
+
12
+ class HistorieHtmlTranslator(html5_polyglot.HTMLTranslator):
13
+
14
+ def visit_reference(self, node: nodes.reference):
15
+ if 'his-link' in node.get('classes', []):
16
+ refuri = node.get('refuri', '')
17
+ if refuri:
18
+ favicon = utility.fetch_favicon(refuri)
19
+ self.body.append(f'<img src="{favicon}" class="favicon" alt="" />')
20
+
21
+ super().visit_reference(node)
22
+
23
+ def visit_paragraph(self, node):
24
+ if 'his-section-title' in node.get('classes', []):
25
+ self.body.append(self.starttag(node, 'h2', ''))
26
+ self.context.append('</h2>\n')
27
+ else:
28
+ super().visit_paragraph(node)
29
+
30
+ def depart_paragraph(self, node):
31
+ if 'his-section-title' in node.get('classes', []):
32
+ self.body.append(self.context.pop())
33
+ else:
34
+ super().depart_paragraph(node)
35
+
36
+ def visit_list_item(self, node):
37
+ if 'his-contacts-name' in node.get('classes', []):
38
+ self.body.append(self.starttag(node, 'h1', ''))
39
+ self.context.append('</h1>\n')
40
+ else:
41
+ super().visit_list_item(node)
42
+
43
+ def depart_list_item(self, node):
44
+ if 'his-contacts-name' in node.get('classes', []):
45
+ self.body.append(self.context.pop())
46
+ else:
47
+ super().depart_list_item(node)
48
+
49
+
50
+ class HistorieHtmlWriter(html5_polyglot.Writer):
51
+
52
+ config_section = 'historie writer'
53
+
54
+ def __init__(self):
55
+ super().__init__()
56
+ self.translator_class = HistorieHtmlTranslator
57
+
58
+ settings_default_overrides = {
59
+ 'stylesheet_path': [str(Path(__file__).parent / 'static' / 'style.css')],
60
+ 'embed_stylesheet': True,
61
+ }
@@ -0,0 +1,5 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#718096" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
2
+ <circle cx="12" cy="12" r="10"/>
3
+ <line x1="2" y1="12" x2="22" y2="12"/>
4
+ <path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
5
+ </svg>