libreqda 1.2.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.
Files changed (255) hide show
  1. CHANGELOG.md +481 -0
  2. libreqda-1.2.0.dist-info/LICENSE.txt +28 -0
  3. libreqda-1.2.0.dist-info/METADATA +131 -0
  4. libreqda-1.2.0.dist-info/RECORD +255 -0
  5. libreqda-1.2.0.dist-info/WHEEL +4 -0
  6. libreqda-1.2.0.dist-info/entry_points.txt +4 -0
  7. scripts/babelrc +8 -0
  8. scripts/dist/LICENSE.txt +33 -0
  9. scripts/dist/RELEASE.txt +23 -0
  10. scripts/dist/macos/libreqda.icns +0 -0
  11. scripts/dist/macos/macos.spec +43 -0
  12. scripts/dist/macos/plist.patch +10 -0
  13. scripts/dist/macos/taguette.icns +0 -0
  14. scripts/dist/macos/taguette_console_wrapper +4 -0
  15. scripts/dist/macos.sh +32 -0
  16. scripts/dist/pyinstaller_entrypoint.py +19 -0
  17. scripts/dist/windows/windows-installer.iss +51 -0
  18. scripts/dist/windows/windows.spec +39 -0
  19. scripts/edugain/worker_update_edugain_idps.py +89 -0
  20. scripts/edugain/worker_update_edugain_idps.sh +9 -0
  21. scripts/make_password_reset_link.py +56 -0
  22. scripts/make_theme.py +99 -0
  23. scripts/new_db_revision.sh +8 -0
  24. scripts/register_user.py +76 -0
  25. scripts/update_pot.sh +33 -0
  26. scripts/update_translations.sh +17 -0
  27. scripts/validate.py +140 -0
  28. scripts/wipe_and_disable_user.py +90 -0
  29. taguette/__init__.py +13 -0
  30. taguette/access_control.py +145 -0
  31. taguette/convert.py +623 -0
  32. taguette/database/__init__.py +177 -0
  33. taguette/database/base.py +9 -0
  34. taguette/database/copy.py +433 -0
  35. taguette/database/models.py +920 -0
  36. taguette/demodata.py +688 -0
  37. taguette/export.py +500 -0
  38. taguette/extract.py +287 -0
  39. taguette/import_codebook.py +110 -0
  40. taguette/l10n/de_DE/LC_MESSAGES/taguette_javascript.mo +0 -0
  41. taguette/l10n/de_DE/LC_MESSAGES/taguette_main.mo +0 -0
  42. taguette/l10n/el_EL/LC_MESSAGES/taguette_javascript.mo +0 -0
  43. taguette/l10n/el_GR/LC_MESSAGES/taguette_javascript.mo +0 -0
  44. taguette/l10n/el_GR/LC_MESSAGES/taguette_main.mo +0 -0
  45. taguette/l10n/en_US/LC_MESSAGES/taguette_javascript.mo +0 -0
  46. taguette/l10n/en_US/LC_MESSAGES/taguette_main.mo +0 -0
  47. taguette/l10n/es_ES/LC_MESSAGES/taguette_javascript.mo +0 -0
  48. taguette/l10n/es_ES/LC_MESSAGES/taguette_main.mo +0 -0
  49. taguette/l10n/fr_FR/LC_MESSAGES/taguette_javascript.mo +0 -0
  50. taguette/l10n/fr_FR/LC_MESSAGES/taguette_main.mo +0 -0
  51. taguette/l10n/id_ID/LC_MESSAGES/taguette_javascript.mo +0 -0
  52. taguette/l10n/id_ID/LC_MESSAGES/taguette_main.mo +0 -0
  53. taguette/l10n/it_IT/LC_MESSAGES/taguette_javascript.mo +0 -0
  54. taguette/l10n/it_IT/LC_MESSAGES/taguette_main.mo +0 -0
  55. taguette/l10n/nl_NL/LC_MESSAGES/taguette_javascript.mo +0 -0
  56. taguette/l10n/nl_NL/LC_MESSAGES/taguette_main.mo +0 -0
  57. taguette/l10n/pt_PT/LC_MESSAGES/taguette_javascript.mo +0 -0
  58. taguette/l10n/pt_PT/LC_MESSAGES/taguette_main.mo +0 -0
  59. taguette/l10n/sv_SE/LC_MESSAGES/taguette_javascript.mo +0 -0
  60. taguette/l10n/sv_SE/LC_MESSAGES/taguette_main.mo +0 -0
  61. taguette/loki_handler.py +107 -0
  62. taguette/main.py +649 -0
  63. taguette/migrations/env.py +72 -0
  64. taguette/migrations/script.py.mako +24 -0
  65. taguette/migrations/versions/09c662cd9483_make_documents_filename_not_null.py +39 -0
  66. taguette/migrations/versions/1b29e68900c1_add_user_roles.py +55 -0
  67. taguette/migrations/versions/1b96a606551d_update_user_to_have_a_display_name.py +34 -0
  68. taguette/migrations/versions/2467bab602b7_add_color_column_to_tags_table.py +33 -0
  69. taguette/migrations/versions/382db36f20f4_add_user_disabled.py +66 -0
  70. taguette/migrations/versions/43d6c240309d_remove_document_groups.py +81 -0
  71. taguette/migrations/versions/447d636f72c5_cascades.py +40 -0
  72. taguette/migrations/versions/491de2dc7cd7_add_indexes.py +51 -0
  73. taguette/migrations/versions/4c55b3b3ff30_user_login_length.py +58 -0
  74. taguette/migrations/versions/61a4abcc8769_user_with_parent_for_permissions.py +40 -0
  75. taguette/migrations/versions/6489b5f9cfb5_increase_password_hash_size.py +34 -0
  76. taguette/migrations/versions/679f625e6e6a_fix_unique_tags.py +59 -0
  77. taguette/migrations/versions/77c4543a5b9b_add_parent_id_column_to_collection_table.py +41 -0
  78. taguette/migrations/versions/7c94b7a31e69_add_is_archived_column_to_projets.py +39 -0
  79. taguette/migrations/versions/7f56de97e3c8_change_edugain_displayname_length.py +42 -0
  80. taguette/migrations/versions/807c0cc0ecf6_remove_invalid_hltags.py +43 -0
  81. taguette/migrations/versions/80b1cc9d4c22_add_user_email.py +35 -0
  82. taguette/migrations/versions/91ade71ccf4d_add_user_language.py +28 -0
  83. taguette/migrations/versions/955db2e523d1_add_collections_tables_and_associations_.py +60 -0
  84. taguette/migrations/versions/a1b2c3d4e5f6_add_user_hide_code_counts.py +28 -0
  85. taguette/migrations/versions/b23f3b7a638e_add_user_password_change_date.py +36 -0
  86. taguette/migrations/versions/b7c1e2d3f4a5_add_project_compact_layout.py +28 -0
  87. taguette/migrations/versions/bc8e0e0677e9_rename_command_fields.py +126 -0
  88. taguette/migrations/versions/bce44849c2f2_lowercase_logins.py +96 -0
  89. taguette/migrations/versions/ca076140922f_update_user_for_external_logins.py +32 -0
  90. taguette/migrations/versions/d86c45d7aa8a_add_document_direction.py +127 -0
  91. taguette/migrations/versions/db5e31a0233d_remove_blank_highlights.py +88 -0
  92. taguette/migrations/versions/dd37d9027a24_unique_tags.py +68 -0
  93. taguette/migrations/versions/de678cef2a94_add_project_id_to_collections_and_.py +41 -0
  94. taguette/migrations/versions/e459991d960e_add_table_edugainidps.py +39 -0
  95. taguette/migrations/versions/e4cf92942271_add_index.py +29 -0
  96. taguette/migrations/versions/e4e090a5b511_remove_commands_document_id_fk.py +38 -0
  97. taguette/migrations/versions/e9a9f1c77971_add_favorite_column_to_document.py +32 -0
  98. taguette/migrations/versions/ecb4065de575_don_t_use_server_default_now_timezone_.py +44 -0
  99. taguette/migrations/versions/fbdc29566f62_edugain_fix_field_type.py +42 -0
  100. taguette/migrations/versions/fdc6eb54b034_add_parent_id_column_to_tags_table.py +31 -0
  101. taguette/static/README.md +34 -0
  102. taguette/static/alt_templates/.gitkeep +0 -0
  103. taguette/static/assets/test/logos/monsters-university-logo.png +0 -0
  104. taguette/static/css/custom.css +1696 -0
  105. taguette/static/css/fa-all.min.css +5 -0
  106. taguette/static/css/themed.css +3 -0
  107. taguette/static/css/themed.css.map +25 -0
  108. taguette/static/css/vendor/bootstrap-treeview.min.css +1 -0
  109. taguette/static/css/vendor/bootstrap.min.css +6 -0
  110. taguette/static/css/vendor/font/context-menu-icons.eot +0 -0
  111. taguette/static/css/vendor/font/context-menu-icons.ttf +0 -0
  112. taguette/static/css/vendor/font/context-menu-icons.woff +0 -0
  113. taguette/static/css/vendor/font/context-menu-icons.woff2 +0 -0
  114. taguette/static/css/vendor/hierarchy-select.min.css +1 -0
  115. taguette/static/css/vendor/introjs.min.css +2 -0
  116. taguette/static/css/vendor/jquery.contextMenu.min.css +16 -0
  117. taguette/static/favicon.ico +0 -0
  118. taguette/static/js/codebook-viz.js +428 -0
  119. taguette/static/js/collection/context-menu.js +180 -0
  120. taguette/static/js/collection/hierarchy-select.js +71 -0
  121. taguette/static/js/collection/manager.js +217 -0
  122. taguette/static/js/document/context-menu.js +92 -0
  123. taguette/static/js/document/custom-select.js +82 -0
  124. taguette/static/js/document/manager.js +943 -0
  125. taguette/static/js/document/treeview.js +593 -0
  126. taguette/static/js/highlight/README.md +18 -0
  127. taguette/static/js/highlight/crud.js +119 -0
  128. taguette/static/js/highlight/extracts.js +497 -0
  129. taguette/static/js/highlight/loader.js +109 -0
  130. taguette/static/js/highlight/renderer.js +405 -0
  131. taguette/static/js/login.js +15 -0
  132. taguette/static/js/permissions.js +153 -0
  133. taguette/static/js/polling-event.js +145 -0
  134. taguette/static/js/project/intro.js +524 -0
  135. taguette/static/js/project/manager.js +0 -0
  136. taguette/static/js/project/search.js +124 -0
  137. taguette/static/js/tag/context-menu.js +133 -0
  138. taguette/static/js/tag/explore-codes.js +400 -0
  139. taguette/static/js/tag/hierarchy-select.js +69 -0
  140. taguette/static/js/tag/manager.js +545 -0
  141. taguette/static/js/tag/treeview.js +613 -0
  142. taguette/static/js/taguette.js +1790 -0
  143. taguette/static/js/utilities.js +714 -0
  144. taguette/static/js/vendor/bootstrap-treeview-custum.js +421 -0
  145. taguette/static/js/vendor/bootstrap-treeview-custum.min.js +1 -0
  146. taguette/static/js/vendor/bootstrap-treeview-default.min.js +1 -0
  147. taguette/static/js/vendor/bootstrap.bundle.min.js +7 -0
  148. taguette/static/js/vendor/bootstrap.bundle.min.js.map +1 -0
  149. taguette/static/js/vendor/hierarchy-select.min.js +1 -0
  150. taguette/static/js/vendor/introjs.min.js +17 -0
  151. taguette/static/js/vendor/jquery-3.7.1.js +2 -0
  152. taguette/static/js/vendor/jquery.contextMenu.min.js +2 -0
  153. taguette/static/libreqda.ico +0 -0
  154. taguette/static/logo-h.png +0 -0
  155. taguette/static/logo.png +0 -0
  156. taguette/static/logo_blanc.png +0 -0
  157. taguette/static/missing.png +0 -0
  158. taguette/static/robots.txt +2 -0
  159. taguette/static/scss/components/_badge.scss +42 -0
  160. taguette/static/scss/components/_button.scss +169 -0
  161. taguette/static/scss/components/_checkbox.scss +40 -0
  162. taguette/static/scss/components/_context_menu.scss +28 -0
  163. taguette/static/scss/components/_custom_select.scss +150 -0
  164. taguette/static/scss/components/_dropdown.scss +43 -0
  165. taguette/static/scss/components/_form.scss +54 -0
  166. taguette/static/scss/components/_modal.scss +57 -0
  167. taguette/static/scss/components/_project_card.scss +163 -0
  168. taguette/static/scss/components/_tag.scss +308 -0
  169. taguette/static/scss/components/_treeview.scss +76 -0
  170. taguette/static/scss/custom.scss +809 -0
  171. taguette/static/scss/layout/_credits.scss +62 -0
  172. taguette/static/scss/layout/_footer.scss +6 -0
  173. taguette/static/scss/layout/_navigation.scss +165 -0
  174. taguette/static/scss/layout/_sidebar.scss +37 -0
  175. taguette/static/scss/themes/default/_variables.scss +103 -0
  176. taguette/static/scss/themes/default/theme.css +3 -0
  177. taguette/static/scss/themes/default/theme.css.map +26 -0
  178. taguette/static/scss/themes/default/theme.scss +4 -0
  179. taguette/static/webfonts/BadScript-OFL.txt +94 -0
  180. taguette/static/webfonts/BadScript.ttf +0 -0
  181. taguette/static/webfonts/Montserrat-Light.ttf +0 -0
  182. taguette/static/webfonts/Montserrat-OFL.txt +93 -0
  183. taguette/static/webfonts/fa-brands-400.eot +0 -0
  184. taguette/static/webfonts/fa-brands-400.svg +1184 -0
  185. taguette/static/webfonts/fa-brands-400.ttf +0 -0
  186. taguette/static/webfonts/fa-brands-400.woff +0 -0
  187. taguette/static/webfonts/fa-brands-400.woff2 +0 -0
  188. taguette/static/webfonts/fa-regular-400.eot +0 -0
  189. taguette/static/webfonts/fa-regular-400.svg +467 -0
  190. taguette/static/webfonts/fa-regular-400.ttf +0 -0
  191. taguette/static/webfonts/fa-regular-400.woff +0 -0
  192. taguette/static/webfonts/fa-regular-400.woff2 +0 -0
  193. taguette/static/webfonts/fa-solid-900.eot +0 -0
  194. taguette/static/webfonts/fa-solid-900.svg +2618 -0
  195. taguette/static/webfonts/fa-solid-900.ttf +0 -0
  196. taguette/static/webfonts/fa-solid-900.woff +0 -0
  197. taguette/static/webfonts/fa-solid-900.woff2 +0 -0
  198. taguette/templates/alt_templates/.gitkeep +0 -0
  199. taguette/templates/archived_projects.html +50 -0
  200. taguette/templates/auth/account.html +74 -0
  201. taguette/templates/auth/cilogon.login.html +89 -0
  202. taguette/templates/auth/email_reset_password.html +5 -0
  203. taguette/templates/auth/external_account.html +60 -0
  204. taguette/templates/auth/login.html +144 -0
  205. taguette/templates/auth/reset_password.html +27 -0
  206. taguette/templates/auth/token_needed.html +7 -0
  207. taguette/templates/base.html +209 -0
  208. taguette/templates/bug_report.html +158 -0
  209. taguette/templates/cookies_prompt.html +15 -0
  210. taguette/templates/downloads.html +127 -0
  211. taguette/templates/email_bug_report.txt +9 -0
  212. taguette/templates/email_reset_password.txt +5 -0
  213. taguette/templates/error.html +8 -0
  214. taguette/templates/export_codebook.html +20 -0
  215. taguette/templates/export_document.html +20 -0
  216. taguette/templates/export_highlights.html +47 -0
  217. taguette/templates/index.html +347 -0
  218. taguette/templates/maintenance.html +8 -0
  219. taguette/templates/messages.js +74 -0
  220. taguette/templates/modal/change_document.html +68 -0
  221. taguette/templates/modal/create_collection.html +100 -0
  222. taguette/templates/modal/create_document.html +84 -0
  223. taguette/templates/modal/create_highlight.html +38 -0
  224. taguette/templates/modal/create_tag.html +73 -0
  225. taguette/templates/modal/explore_codes.html +58 -0
  226. taguette/templates/modal/manage_collaborators.html +44 -0
  227. taguette/templates/modal/merge_tag.html +49 -0
  228. taguette/templates/new_password.html +32 -0
  229. taguette/templates/partials/alert.html +16 -0
  230. taguette/templates/partials/codebook_viz_modal.html +34 -0
  231. taguette/templates/partials/confirm.html +17 -0
  232. taguette/templates/partials/document_toolbar.html +37 -0
  233. taguette/templates/partials/hlinfo.html +13 -0
  234. taguette/templates/partials/project_card.html +44 -0
  235. taguette/templates/partials/project_card_archived.html +41 -0
  236. taguette/templates/partials/project_sidebar.html +99 -0
  237. taguette/templates/partials/spinner.html +11 -0
  238. taguette/templates/permissions.html +54 -0
  239. taguette/templates/project.html +288 -0
  240. taguette/templates/project_delete.html +17 -0
  241. taguette/templates/project_import.html +122 -0
  242. taguette/templates/project_import_codebook.html +34 -0
  243. taguette/templates/project_import_codebook_confirm.html +49 -0
  244. taguette/templates/project_new.html +31 -0
  245. taguette/templates/tos.html +8 -0
  246. taguette/templates/trans.js +7 -0
  247. taguette/templates/welcome.html +15 -0
  248. taguette/utils.py +177 -0
  249. taguette/validate.py +141 -0
  250. taguette/version_check.py +102 -0
  251. taguette/web/__init__.py +252 -0
  252. taguette/web/api.py +1835 -0
  253. taguette/web/base.py +864 -0
  254. taguette/web/export.py +338 -0
  255. taguette/web/views.py +1618 -0
taguette/extract.py ADDED
@@ -0,0 +1,287 @@
1
+ from bs4 import BeautifulSoup, NavigableString
2
+ import opentelemetry.trace
3
+ import prometheus_client
4
+
5
+
6
+ tracer = opentelemetry.trace.get_tracer(__name__)
7
+
8
+
9
+ BUCKETS = [0.001, 0.002, 0.003, 0.004,
10
+ 0.006, 0.008, 0.010,
11
+ 0.015, 0.020, 0.025, 0.030, 0.035, 0.04, 0.045, 0.05,
12
+ 0.06, 0.07, 0.08, 0.09, 0.10, 0.15, 0.20, 0.5, 1.0]
13
+ PROM_EXTRACT_TIME = prometheus_client.Histogram(
14
+ 'html_extract_seconds',
15
+ "Time to extract part of an HTML document (extract.extract())",
16
+ buckets=BUCKETS,
17
+ )
18
+ PROM_HIGHLIGHT_TIME = prometheus_client.Histogram(
19
+ 'html_highlight_seconds',
20
+ "Time to add highlight tags to an HTML document (extract.highlight())",
21
+ buckets=BUCKETS,
22
+ )
23
+
24
+
25
+ def split_utf8(s, pos):
26
+ """Split a string at a given UTF-8 byte position.
27
+
28
+ If the position falls inside of a codepoint, the string will be split after
29
+ that codepoint.
30
+ """
31
+ s = s.encode('utf-8')
32
+ while pos < len(s) and 0x80 <= s[pos] <= 0xBF:
33
+ pos += 1
34
+ return s[:pos].decode('utf-8'), s[pos:].decode('utf-8')
35
+
36
+
37
+ def find_pos(node, offset, after):
38
+ """Find a position in an HTML document.
39
+ """
40
+ indices = []
41
+ while True:
42
+ if getattr(node, 'contents', None):
43
+ indices.append(0)
44
+ node = node.contents[0]
45
+ else:
46
+ if isinstance(node, NavigableString):
47
+ nb = len(node.string.encode('utf-8'))
48
+ if (after and nb > offset) or (not after and nb >= offset):
49
+ break
50
+ else:
51
+ offset -= nb
52
+ while not node.next_sibling:
53
+ indices.pop()
54
+ node = node.parent
55
+ indices[-1] += 1
56
+ node = node.next_sibling
57
+ return node, offset, indices
58
+
59
+
60
+ def delete_left(node, indices):
61
+ """Delete the left part of an HTML tree.
62
+ """
63
+ for idx in indices:
64
+ for child in node.contents[:idx]:
65
+ child.extract()
66
+ node = node.contents[0]
67
+
68
+
69
+ def delete_right(node, indices):
70
+ """Delete the right part of an HTML tree.
71
+ """
72
+ for idx in indices:
73
+ del node.contents[idx + 1:]
74
+ node = node.contents[idx]
75
+
76
+
77
+ @tracer.start_as_current_span('taguette/extract')
78
+ @PROM_EXTRACT_TIME.time()
79
+ def extract(html, start, end):
80
+ """Extract a snippet out of an HTML document.
81
+
82
+ Locations are computed over UTF-8 bytes, and doesn't count HTML tags.
83
+
84
+ Extraction is aware of tags, so:
85
+
86
+ >>> '<p><u>Hello</u> there <i>World</i></p>'[17:27]
87
+ 'here <i>Wo'
88
+ >>> extract('<p><u>Hello</u> there <i>World</i></p>', 7, 14)
89
+ '<p>here <i>Wo</i></p>'
90
+ """
91
+ soup = BeautifulSoup(html, 'html5lib')
92
+
93
+ # Trim the right side first, because that doesn't mess our start position
94
+ if end is not None:
95
+ e = find_pos(soup, end, False)
96
+ e[0].replace_with(NavigableString(split_utf8(e[0].string, e[1])[0]))
97
+ delete_right(soup, e[2])
98
+
99
+ # Trim the left side
100
+ if start is not None:
101
+ s = find_pos(soup, start, True)
102
+ s[0].replace_with(NavigableString(split_utf8(s[0].string, s[1])[1]))
103
+ delete_left(soup, s[2])
104
+
105
+ # Remove everything but body
106
+ body = soup.body
107
+ soup.clear()
108
+ soup.append(body)
109
+
110
+ # Remove the body tag itself to only have the contents
111
+ soup.body.unwrap()
112
+
113
+ # Back to text
114
+ return str(soup)
115
+
116
+
117
+ def byte_to_str_index(string, byte_index):
118
+ """Converts a byte index in the UTF-8 string into a codepoint index.
119
+
120
+ If the index falls inside of a codepoint unit, it will be rounded down.
121
+ """
122
+ for idx, char in enumerate(string):
123
+ char_size = len(char.encode('utf-8'))
124
+ if char_size > byte_index:
125
+ return idx
126
+ byte_index -= char_size
127
+ return len(string)
128
+
129
+
130
+ @tracer.start_as_current_span('taguette/highlight')
131
+ @PROM_HIGHLIGHT_TIME.time()
132
+ def highlight(html, highlights, show_tags=False):
133
+ """Highlight part of an HTML documents.
134
+
135
+ :param highlights: Iterable of (start, end, tags) triples, which are
136
+ computed over UTF-8 bytes and don't count HTML tags
137
+ :param show_tags: Whether to show the tag names within brackets after each
138
+ highlight
139
+ """
140
+ # Build a list of starting points and ending points
141
+ starts = []
142
+ ends = []
143
+ for hl in highlights:
144
+ starts.append((hl[0], 'start', []))
145
+ if len(hl) == 2:
146
+ ends.append((hl[1], 'end', []))
147
+ else:
148
+ ends.append((hl[1], 'end', hl[2]))
149
+ # This relies on the fact that 'end' < 'start'
150
+ events = sorted(ends + starts)
151
+
152
+ events = iter(events)
153
+ soup = BeautifulSoup(html, 'html5lib')
154
+
155
+ pos = 0
156
+ node = soup
157
+ highlighting = 0
158
+ try:
159
+ event_pos, event_type, tags = next(events)
160
+ except StopIteration:
161
+ event_pos = event_type = tags = None
162
+
163
+ while node is not None:
164
+ if getattr(node, 'contents', None):
165
+ # Move down
166
+ node = node.contents[0]
167
+ continue
168
+
169
+ if isinstance(node, NavigableString):
170
+ # Move through text
171
+ nb = len(node.string.encode('utf-8'))
172
+ while event_pos is not None:
173
+ if event_pos == pos and event_type == 'start':
174
+ # Start highlighting at beginning of text node
175
+ highlighting += 1
176
+ try:
177
+ event_pos, event_type, tags = next(events)
178
+ except StopIteration:
179
+ event_pos = None
180
+ elif pos + nb > event_pos:
181
+ # Next event falls inside of this text node
182
+ if event_type == 'start' and highlighting:
183
+ # Keep highlighting (can't highlight *more*)
184
+ highlighting += 1
185
+ elif (
186
+ event_type == 'end'
187
+ and not show_tags
188
+ and highlighting > 1
189
+ ):
190
+ # Keep highlighting (no need to put labels)
191
+ highlighting -= 1
192
+ else: # 'end' and (show_tags or highlighting becomes 0)
193
+ # Split it
194
+ char_idx = byte_to_str_index(
195
+ node.string,
196
+ event_pos - pos,
197
+ )
198
+ left = node.string[:char_idx]
199
+ right = node.string[char_idx:]
200
+
201
+ # Left part
202
+ newnode = NavigableString(left)
203
+ if highlighting:
204
+ # Optionally highlight left part
205
+ span = soup.new_tag(
206
+ 'span',
207
+ attrs={'class': 'highlight'},
208
+ )
209
+ span.append(newnode)
210
+ newnode = span
211
+ node.replace_with(newnode)
212
+ node = newnode
213
+
214
+ if event_type == 'start':
215
+ highlighting += 1
216
+ else:
217
+ highlighting -= 1
218
+ if show_tags:
219
+ # Add tag labels
220
+ comment = soup.new_tag(
221
+ 'span',
222
+ attrs={'class': 'taglist'},
223
+ )
224
+ comment.string = ' [%s]' % ', '.join(tags)
225
+ node.insert_after(comment)
226
+ node = comment
227
+
228
+ # Right part
229
+ newnode = NavigableString(right)
230
+ node.insert_after(newnode)
231
+ node = newnode
232
+ nb -= event_pos - pos
233
+ pos = event_pos
234
+ # Next loop will highlight right part if needed
235
+
236
+ try:
237
+ event_pos, event_type, tags = next(events)
238
+ except StopIteration:
239
+ event_pos = None
240
+ elif highlighting: # and pos + nb <= event_pos:
241
+ # Highlight whole text node
242
+ newnode = soup.new_tag(
243
+ 'span',
244
+ attrs={'class': 'highlight'},
245
+ )
246
+ node.replace_with(newnode)
247
+ newnode.append(node)
248
+ node = newnode
249
+ if pos + nb == event_pos and event_type == 'end':
250
+ if show_tags:
251
+ comment = soup.new_tag(
252
+ 'span',
253
+ attrs={'class': 'taglist'},
254
+ )
255
+ comment.string = ' [%s]' % ', '.join(tags)
256
+ newnode.insert_after(comment)
257
+ node = comment
258
+ highlighting -= 1
259
+ try:
260
+ event_pos, event_type, tags = next(events)
261
+ except StopIteration:
262
+ event_pos = None
263
+ break
264
+ else: # not highlighting and pos + nb <= event_pos
265
+ # Skip whole text node
266
+ break
267
+
268
+ pos += nb
269
+
270
+ # Move up until there's a sibling
271
+ while not node.next_sibling and node.parent:
272
+ node = node.parent
273
+ if not node.parent:
274
+ break
275
+ # Move to next node
276
+ node = node.next_sibling
277
+
278
+ # Remove everything but body
279
+ body = soup.body
280
+ soup.clear()
281
+ soup.append(body)
282
+
283
+ # Remove the body tag itself to only have the contents
284
+ soup.body.unwrap()
285
+
286
+ # Back to text
287
+ return str(soup)
@@ -0,0 +1,110 @@
1
+ import chardet
2
+ import codecs
3
+ import csv
4
+
5
+ from .utils import _f
6
+
7
+
8
+ class InvalidCodebook(ValueError):
9
+ """File can't be read as a codebook.
10
+ """
11
+
12
+ def __init__(self, message, row=None):
13
+ super(InvalidCodebook, self).__init__(message)
14
+ self.message = message
15
+ self.row = row
16
+
17
+
18
+ def list_tags_csv(reader):
19
+ detector = chardet.UniversalDetector()
20
+ chunk = reader.read(4096)
21
+ while chunk and not detector.done:
22
+ detector.feed(chunk)
23
+ chunk = reader.read(4096)
24
+ detector.close()
25
+ charset = detector.result['encoding'] or 'utf-8'
26
+ reader.seek(0, 0)
27
+
28
+ reader = codecs.getreader(charset)(reader)
29
+ reader = csv.reader(reader)
30
+
31
+ # Read header
32
+ try:
33
+ header = next(reader)
34
+ except (StopIteration, csv.Error, UnicodeDecodeError):
35
+ raise InvalidCodebook(_f("Invalid file: CSV expected"))
36
+ header = [cell.lower() for cell in header]
37
+
38
+ # Figure out which column has the tag path
39
+ expected = ('tag' in header) + ('name' in header) + ('path' in header)
40
+ if expected > 1:
41
+ raise InvalidCodebook(
42
+ _f("Not sure which column to use for tag name"),
43
+ )
44
+ elif expected == 0:
45
+ raise InvalidCodebook(_f("No 'tag', 'name', or 'path' column"))
46
+ else:
47
+ if 'tag' in header:
48
+ col_path = header.index('tag')
49
+ elif 'name' in header:
50
+ col_path = header.index('name')
51
+ else:
52
+ col_path = header.index('path')
53
+
54
+ # Is there a description?
55
+ try:
56
+ col_description = header.index('description')
57
+ except ValueError:
58
+ col_description = None
59
+
60
+ # Is there a color?
61
+ try:
62
+ col_color = header.index('color')
63
+ except ValueError:
64
+ col_color = None
65
+
66
+ # Is there a parent?
67
+ try:
68
+ col_parent = header.index('parent')
69
+ except ValueError:
70
+ col_parent = None
71
+
72
+ needed_rows = max(
73
+ num + 1
74
+ for num in (col_path, col_color, col_parent, col_description)
75
+ if num is not None
76
+ )
77
+
78
+ # Read file
79
+ try:
80
+ tags = []
81
+ for nb, row in enumerate(reader, 2):
82
+ if len(row) < needed_rows:
83
+ raise InvalidCodebook(_f("Not enough columns"), nb)
84
+ if not any(row):
85
+ continue # Skip empty row
86
+ path = row[col_path].strip()
87
+ if not path:
88
+ raise InvalidCodebook(_f("Empty tag name"), nb)
89
+ if col_description is not None:
90
+ description = row[col_description].strip()
91
+ else:
92
+ description = ''
93
+ if col_color is not None:
94
+ color = row[col_color].strip()
95
+ else:
96
+ color = ''
97
+ if col_parent is not None:
98
+ parent = row[col_parent].strip()
99
+ else:
100
+ parent = ''
101
+ tags.append({'path': path, 'color': color,
102
+ 'parent': parent, 'description': description})
103
+ except (csv.Error, UnicodeDecodeError):
104
+ raise InvalidCodebook(_f("Invalid CSV file"))
105
+
106
+ return tags
107
+
108
+
109
+ def list_tags(reader):
110
+ return list_tags_csv(reader)
@@ -0,0 +1,107 @@
1
+ import logging
2
+ import requests
3
+ import time
4
+
5
+
6
+ class LokiHandler(logging.Handler):
7
+
8
+ def __init__(self, loki_url: str, job_name: str = "libreqda"):
9
+ super().__init__()
10
+ self.loki_url = f"{loki_url}/loki/api/v1/push"
11
+ self.job_name = job_name
12
+ self.buffer = []
13
+ self.buffer_size = 10
14
+
15
+ def emit(self, record):
16
+ try:
17
+ message = self.format(record)
18
+ level = record.levelname.lower()
19
+
20
+ labels = {
21
+ "job": self.job_name,
22
+ "level": level,
23
+ "logger": record.name
24
+ }
25
+
26
+ if hasattr(record, 'user_id'):
27
+ labels["user_id"] = str(record.user_id)
28
+ if hasattr(record, 'project_id'):
29
+ labels["project_id"] = str(record.project_id)
30
+
31
+ timestamp = str(int(time.time() * 1000000000))
32
+
33
+ self.buffer.append({
34
+ "stream": labels,
35
+ "values": [[timestamp, message]]
36
+ })
37
+
38
+ if len(self.buffer) >= self.buffer_size:
39
+ self._send_buffer()
40
+
41
+ except Exception:
42
+ pass
43
+
44
+ def _send_buffer(self):
45
+ if not self.buffer:
46
+ return
47
+
48
+ try:
49
+ payload = {"streams": self.buffer}
50
+ response = requests.post(
51
+ self.loki_url,
52
+ json=payload,
53
+ timeout=5
54
+ )
55
+ if response.status_code == 204:
56
+ self.buffer.clear()
57
+ except Exception:
58
+ self.buffer.clear()
59
+
60
+ def close(self):
61
+ self._send_buffer()
62
+ super().close()
63
+
64
+
65
+ def test_loki_connectivity(loki_url: str):
66
+ """Test if Loki is accessible and ready"""
67
+ try:
68
+ import requests
69
+ response = requests.get(f"{loki_url}/ready", timeout=5)
70
+ if response.status_code == 200:
71
+ return True, "Loki is ready"
72
+ else:
73
+ return False, f"Loki not ready (status: {response.status_code})"
74
+ except requests.exceptions.ConnectionError:
75
+ return False, f"Loki not accessible at {loki_url}"
76
+ except Exception as e:
77
+ return False, f"Loki test error: {e}"
78
+
79
+
80
+ def setup_loki_logging(loki_url: str):
81
+ # Test connectivity first
82
+ is_ready, message = test_loki_connectivity(loki_url)
83
+ logger = logging.getLogger(__name__)
84
+
85
+ if is_ready:
86
+ logger.info(message)
87
+ else:
88
+ logger.warning(message)
89
+
90
+ loki_handler = LokiHandler(loki_url)
91
+ loki_handler.setLevel(logging.WARNING)
92
+ formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s")
93
+ loki_handler.setFormatter(formatter)
94
+
95
+ # Filter out tornado.access logs from Loki
96
+ class ExcludeTornadoAccess(logging.Filter):
97
+ def filter(self, record):
98
+ return not record.name.startswith("tornado.access")
99
+ loki_handler.addFilter(ExcludeTornadoAccess())
100
+
101
+ root_logger = logging.getLogger()
102
+ root_logger.addHandler(loki_handler)
103
+
104
+ # Test log
105
+ logger.info("Loki test log - %s", loki_url)
106
+
107
+ return loki_handler