├── screenshot.png ├── LICENSE.rst ├── README.rst └── python-coverage.el /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wbolster/emacs-python-coverage/HEAD/screenshot.png -------------------------------------------------------------------------------- /LICENSE.rst: -------------------------------------------------------------------------------- 1 | BSD-3-clause License 2 | ==================== 3 | 4 | Copyright © 2019, wouter bolsterlee 5 | 6 | All rights reserved. 7 | 8 | Redistribution and use in source and binary forms, with or without 9 | modification, are permitted provided that the following conditions are met: 10 | 11 | * Redistributions of source code must retain the above copyright notice, this 12 | list of conditions and the following disclaimer. 13 | 14 | * Redistributions in binary form must reproduce the above copyright notice, this 15 | list of conditions and the following disclaimer in the documentation and/or 16 | other materials provided with the distribution. 17 | 18 | * Neither the name of the author nor the names of the contributors may be used 19 | to endorse or promote products derived from this software without specific 20 | prior written permission. 21 | 22 | This software is provided by the copyright holders and contributors "as is" and 23 | any express or implied warranties, including, but not limited to, the implied 24 | warranties of merchantability and fitness for a particular purpose are 25 | disclaimed. In no event shall the copyright holder or contributors be liable 26 | for any direct, indirect, incidental, special, exemplary, or consequential 27 | damages (including, but not limited to, procurement of substitute goods or 28 | services; loss of use, data, or profits; or business interruption) however 29 | caused and on any theory of liability, whether in contract, strict liability, 30 | or tort (including negligence or otherwise) arising in any way out of the use 31 | of this software, even if advised of the possibility of such damage. 32 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | python-coverage.el 3 | ================== 4 | 5 | This is an experimental Emacs package to report code coverage output 6 | produced by Python's ``coverage`` package directly inside Emacs 7 | buffers. 8 | 9 | ``coverage``'s xml output follows the cobertura format, it should work with 10 | similar reports from other tools, like C/C++ with ``gcovr``. 11 | 12 | Best used together with `python-pytest.el`__. 13 | 14 | __ https://github.com/wbolster/emacs-python-pytest 15 | 16 | 17 | Screenshot 18 | ========== 19 | 20 | .. image:: screenshot.png 21 | :alt: mandatory screenshot 22 | 23 | Overlays 24 | ======== 25 | 26 | Minor mode for automagically updated overlays: 27 | 28 | - ``python-coverage-overlay-mode`` 29 | 30 | Alternatively, commands for manually updated overlays: 31 | 32 | - ``python-coverage-overlay-refresh`` 33 | - ``python-coverage-overlay-remove-all`` 34 | 35 | Navigation commands: 36 | 37 | - ``python-coverage-overlay-jump-next`` 38 | - ``python-coverage-overlay-jump-previous`` 39 | - ``python-coverage-overlay-jump-first`` 40 | 41 | Flycheck checker 42 | ================ 43 | 44 | Run ``flycheck-select-checker``, pick ``python-coverage``. 45 | 46 | Coverage data 47 | ============= 48 | 49 | This package reads the XML output produced by Python's ``coverage`` 50 | package. Usually this file is named ``coverage.xml``. 51 | 52 | With plain `coverage`: 53 | 54 | .. code-block:: shell 55 | 56 | $ coverage xml 57 | 58 | With ``pytest-cov``, pass ``--cov-report=xml``, e.g. via ``pyproject.toml``: 59 | 60 | .. code-block:: toml 61 | 62 | [tool.pytest.ini_options] 63 | addopts = [ 64 | "--cov=your-package", 65 | "--cov=test", 66 | "--cov-report=xml", 67 | ] 68 | 69 | Customization 70 | ============= 71 | 72 | Command for manual coverage file selection: 73 | 74 | - ``python-coverage-select-coverage-file`` 75 | 76 | Customizable settings (see their description for details) in the 77 | ``python-coverage`` group, e.g. via ``M-x customize-group``: 78 | 79 | - ``python-coverage-default-file-name`` 80 | - ``python-coverage-overlay-width`` 81 | 82 | Styling via custom faces, e.g. via ``M-x customize-face``: 83 | 84 | - ``python-coverage-overlay-missing`` 85 | - ``python-coverage-overlay-partial`` 86 | - ``python-coverage-overlay-missing-outdated`` 87 | - ``python-coverage-overlay-partial-outdated`` 88 | 89 | Note: by default this package inherits from ``magit-diff-*`` faces, 90 | which should work in most themes. This requires ``magit`` to be 91 | installed, but it's not otherwise used. 92 | 93 | Credits 94 | ======= 95 | 96 | This package was created by `wouter bolsterlee (@wbolster)`__. 97 | 98 | __ https://github.com/wbolster 99 | 100 | License 101 | ======= 102 | 103 | BSD; see ``LICENSE.rst``. 104 | -------------------------------------------------------------------------------- /python-coverage.el: -------------------------------------------------------------------------------- 1 | ;;; python-coverage.el --- Show Python coverage via overlays or Flycheck -*- lexical-binding: t; -*- 2 | 3 | ;; Author: wouter bolsterlee 4 | ;; Version: 1.0.0 5 | ;; Package-Requires: ((emacs "25.1") (dash "2.18.0") (s "1.12.0") (xml+ "0")) 6 | ;; Keywords: languages, processes, tools 7 | ;; URL: https://github.com/wbolster/emacs-python-coverage 8 | 9 | ;;; License: 10 | 11 | ;; BSD-3-clause License 12 | 13 | ;;; Commentary: 14 | 15 | ;; Show Python coverage results in source files, 16 | ;; using overlays or with a Flycheck checker. 17 | 18 | ;;; Code: 19 | 20 | (require 'dash) 21 | (require 'filenotify) 22 | (require 'python) 23 | (require 's) 24 | (require 'xml) 25 | (require 'xml+) 26 | 27 | (defgroup python-coverage nil 28 | "Python coverage" 29 | :group 'python 30 | :prefix "python-coverage") 31 | 32 | (defcustom python-coverage-default-file-name "coverage.xml" 33 | "Default file name to use when looking for coverage results." 34 | :group 'python-coverage 35 | :type 'string) 36 | 37 | (defcustom python-coverage-overlay-width nil 38 | "Maximum width of the overlays. 39 | 40 | If nil, highlight the whole statement. If a number, highlight up 41 | to that number of characters, or until the end of line, whichever 42 | comes first. Practically, a small number such as 2 will result in 43 | non-obtrusive colored blocks adjacent to the left margin." 44 | :group 'python-coverage 45 | :type '(choice integer (const :tag "Complete line" nil))) 46 | 47 | (defface python-coverage-overlay-missing 48 | '((t :inherit magit-diff-removed)) 49 | "Overlay face for missing coverage." 50 | :group 'python-coverage) 51 | 52 | (defface python-coverage-overlay-partial 53 | '((t :inherit magit-diff-base)) 54 | "Overlay face for partial (branch) coverage." 55 | :group 'python-coverage) 56 | 57 | (defface python-coverage-overlay-missing-outdated 58 | '((t :inherit magit-diff-context-highlight)) 59 | "Overlay face for potentially outdated missing coverage." 60 | :group 'python-coverage) 61 | 62 | (defface python-coverage-overlay-partial-outdated 63 | '((t :inherit magit-diff-context-highlight)) 64 | "Overlay face for potentially outdated partial (branch) coverage." 65 | :group 'python-coverage) 66 | 67 | (defvar-local python-coverage--coverage-file-name nil 68 | "Coverage file to use for the current buffer.") 69 | 70 | (defvar-local python-coverage--overlay-watch nil 71 | "File watch for automatic overlay refreshing.") 72 | 73 | ;; Public interface 74 | 75 | ;;;###autoload 76 | (defun python-coverage-select-coverage-file (&optional coverage-file-name) 77 | "Explicitly set the COVERAGE-FILE-NAME to use for the current buffer. 78 | 79 | This is only needed if autodetection does not work." 80 | (interactive "f") 81 | (setq python-coverage--coverage-file-name coverage-file-name)) 82 | 83 | ;;;###autoload 84 | (define-minor-mode python-coverage-overlay-mode 85 | "Minor mode to show Python coverage results as overlays." 86 | :lighter " PyCov" 87 | (if python-coverage-overlay-mode 88 | (progn 89 | (python-coverage-overlay-refresh) 90 | (add-hook 'kill-buffer-hook #'python-coverage--overlay-remove-watch nil t) 91 | (add-hook 'after-save-hook #'python-coverage--mark-as-outdated nil t) 92 | (python-coverage--overlay-add-watch)) 93 | (python-coverage--overlay-remove-watch) 94 | (remove-hook 'after-save-hook #'python-coverage--mark-as-outdated t) 95 | (python-coverage-overlay-remove-all))) 96 | 97 | ;;;###autoload 98 | (defun python-coverage-overlay-refresh () 99 | "Refresh the overlays." 100 | (interactive) 101 | (let ((coverage-info (python-coverage-current-buffer))) 102 | (when (python-coverage--coverage-file-outdated?) 103 | (message "Note: coverage file is potentially outdated.")) 104 | (save-restriction 105 | (widen) 106 | (overlay-recenter (point-max)) 107 | (python-coverage-overlay-remove-all) 108 | (python-coverage--overlay-make-all coverage-info)))) 109 | 110 | ;;;###autoload 111 | (defun python-coverage-overlay-remove-all () 112 | "Remove all overlays." 113 | (interactive) 114 | (save-restriction 115 | (widen) 116 | (remove-overlays nil nil 'category 'python-coverage))) 117 | 118 | ;;;###autoload 119 | (defun python-coverage-overlay-jump-next () 120 | "Jump to the next overlay." 121 | (interactive) 122 | (-if-let* ((overlay (-first-item (python-coverage--overlays-in (1+ (point)) nil)))) 123 | (goto-char (overlay-start overlay)) 124 | (user-error "No more coverage overlays in this direction"))) 125 | 126 | ;;;###autoload 127 | (defun python-coverage-overlay-jump-previous () 128 | "Jump to the previous overlay." 129 | (interactive) 130 | (-if-let* ((overlay (-last-item (python-coverage--overlays-in nil (point))))) 131 | (goto-char (overlay-start overlay)) 132 | (user-error "No more coverage overlays in this direction"))) 133 | 134 | ;;;###autoload 135 | (defun python-coverage-overlay-jump-first () 136 | "Jump to the first overlay." 137 | (interactive) 138 | (-if-let* ((overlay (-first-item (python-coverage--overlays-in)))) 139 | (goto-char (overlay-start overlay)) 140 | (user-error "No coverage overlays in buffer"))) 141 | 142 | (defun python-coverage-current-buffer () 143 | "Obtain coverage info for the current buffer." 144 | (-when-let* 145 | ((coverage-file (python-coverage--find-coverage-file-current-buffer)) 146 | (non-empty? (> (python-coverage--file-size coverage-file) 0)) 147 | (tree (python-coverage--parse-coverage-xml-file coverage-file)) 148 | (coverage-info (python-coverage--get-missing-file-coverage tree (buffer-file-name) coverage-file))) 149 | coverage-info)) 150 | 151 | ;; Internal helpers for handling files 152 | 153 | (defun python-coverage--find-coverage-file-current-buffer () 154 | "Find a coverage file for the current buffer." 155 | (-let [source-file-name 156 | (or (buffer-file-name) 157 | (error "Cannot detect source file name; buffer is not visiting a file"))] 158 | (python-coverage--find-coverage-file source-file-name))) 159 | 160 | (defun python-coverage--find-coverage-file (source-file-name) 161 | "Find a coverage file for SOURCE-FILE-NAME." 162 | (or 163 | python-coverage--coverage-file-name 164 | (-some-> 165 | (python-coverage--locate-dominating-file source-file-name python-coverage-default-file-name) 166 | (file-name-as-directory) 167 | (s-concat python-coverage-default-file-name)) 168 | (error "Could not find coverage file. (Hint: use ‘M-x python-coverage-select-coverage-file’ to choose manually.)"))) 169 | 170 | (declare-function projectile-locate-dominating-file "projectile" (file name)) 171 | 172 | (defun python-coverage--locate-dominating-file (file name) 173 | "Like ‘locate-dominating-file’, using Projectile if available. 174 | 175 | FILE and NAME are handled like ‘locate-dominating-file’ does." 176 | (if (featurep 'projectile) 177 | (projectile-locate-dominating-file file name) 178 | (locate-dominating-file file name))) 179 | 180 | (defun python-coverage--coverage-file-outdated? (&optional file-name) 181 | "Return t when the coverage file for FILE-NAME is outdated." 182 | (unless file-name (setq file-name (buffer-file-name))) 183 | (let* ((coverage-file (python-coverage--find-coverage-file-current-buffer)) 184 | (coverage-mtime (python-coverage--file-mtime coverage-file)) 185 | (file-mtime (python-coverage--file-mtime file-name))) 186 | (< coverage-mtime file-mtime))) 187 | 188 | (defun python-coverage--file-mtime (file-name) 189 | "Get the mtime of FILE-NAME as a float." 190 | (->> (file-attributes file-name) 191 | (nth 5) 192 | (float-time))) 193 | 194 | (defun python-coverage--file-size (file-name) 195 | "Get the size of FILE-NAME." 196 | (->> (file-attributes file-name) 197 | (nth 7))) 198 | 199 | ;; Internal helpers for handling the coverage XML format 200 | 201 | (defun python-coverage--parse-xml-file (name) 202 | "Parse an XML file NAME." 203 | ;; Try to use libxml, and fall back to the slower built-in function. 204 | (or 205 | (if (fboundp 'libxml-parse-xml-region) 206 | (with-temp-buffer 207 | (insert-file-contents name) 208 | (libxml-parse-xml-region (point-min) (point-max))) 209 | (-first-item (xml-parse-file name))) 210 | (error "Could not parse coverage file ‘%s’" name))) 211 | 212 | (defun python-coverage--parse-coverage-xml-file (name) 213 | "Parse the XML file NAME." 214 | (-when-let* ((tree (python-coverage--parse-xml-file name))) 215 | (unless (eq (car tree) 'coverage) 216 | (error "Unknown XML file format; root element should be ")) 217 | tree)) 218 | 219 | (defun python-coverage--get-missing-file-coverage (tree file-name coverage-file) 220 | "Get the missing coverage for FILE-NAME from TREE." 221 | (-when-let (class-node (python-coverage--find-class-node tree file-name coverage-file)) 222 | (python-coverage--extract-lines class-node))) 223 | 224 | (defun python-coverage--find-class-node (tree file-name coverage-file) 225 | "Find the XML node in TREE for the specified FILE-NAME" 226 | ;; Unfortunately, the XML does not contain full file paths. Find all 227 | ;; elements for the base file name, then check if 228 | ;; any of them matches when combined with any of the source paths. 229 | (-if-let* 230 | ((file-name-without-directory (file-name-nondirectory file-name)) 231 | (query `((coverage) > (packages) > (package) > (classes) > (class))) 232 | (class-node-candidates (xml+-query-all tree query)) 233 | (source-paths (python-coverage--get-source-paths tree)) 234 | (class-node 235 | (--first 236 | (python-coverage--class-node-matches-file-name? it file-name source-paths coverage-file) 237 | class-node-candidates))) 238 | class-node 239 | (error "Coverage file contains no information for file ‘%s’" file-name))) 240 | 241 | (defun python-coverage--get-source-paths (tree) 242 | "Get the source paths from the TREE." 243 | (->> (xml+-query-all tree '((coverage) > (sources) > (source))) 244 | (-map 'xml+-node-text))) 245 | 246 | (defun python-coverage--class-node-matches-file-name? (class-node file-name source-paths coverage-file) 247 | "Check whether CLASS-NODE is about FILE-NAME. 248 | 249 | This tries all SOURCE-PATHS and compares that to FILE-NAME. 250 | COVERAGE-FILE path is needed to convert the source directory from relative to absolute." 251 | ;; The ‘filename=...’ attribute contains a relative file name 252 | ;; starting at any of the source directories. 253 | (-let [relative-file-name 254 | (or (xml-get-attribute-or-nil class-node 'filename) 255 | (error " node does not have a ‘filename’ attribute"))] 256 | (->> source-paths 257 | (--map (expand-file-name it (file-name-directory coverage-file))) 258 | (-map 'file-name-as-directory) 259 | (--map (s-concat it relative-file-name)) 260 | (member file-name)))) 261 | 262 | (defun python-coverage--extract-lines (class-node) 263 | "Extract info about lines that are not fully covered from CLASS-NODE." 264 | (->> (xml+-query-all class-node '((class) > (lines) > (line))) 265 | (nreverse) 266 | (-map 'python-coverage--transform-line-node) 267 | (--remove (eq (plist-get it :status) 'covered)) 268 | (-sort (-on '< (-rpartial 'plist-get :line-beg))) 269 | (python-coverage--merge-adjacent))) 270 | 271 | (defun python-coverage--transform-line-node (line-node) 272 | "Transform a LINE-NODE (‘’) into a simple structure." 273 | (let* ((line 274 | (-> line-node 275 | (xml-get-attribute 'number) 276 | (string-to-number))) 277 | (missing? 278 | (-> line-node 279 | (xml-get-attribute 'hits) 280 | (s-equals? "0"))) 281 | (missing-branches 282 | (-> line-node 283 | (xml-get-attribute-or-nil 'missing-branches))) 284 | (status 285 | (cond (missing? 'missing) 286 | (missing-branches 'partial) 287 | (t 'covered))) 288 | (result 289 | (append 290 | (list :line-beg line :line-end line :status status) 291 | (-some->> missing-branches (list :missing-branches))))) 292 | result)) 293 | 294 | (defun python-coverage--merge-adjacent (coverage-info) 295 | "Merge adjacent lines in COVERAGE-INFO into larger blocks." 296 | (nreverse 297 | (--reduce-from 298 | (-if-let* ((previous (car acc)) 299 | (current it) 300 | (previous-line (plist-get previous :line-end)) 301 | (current-line (plist-get it :line-beg)) 302 | (same-status? (eq (plist-get previous :status) 303 | (plist-get current :status))) 304 | (adjacent? (eql (- current-line previous-line) 1)) 305 | (replacement-head (plist-put previous :line-end current-line))) 306 | (cons replacement-head (cdr acc)) 307 | (cons it acc)) 308 | nil 309 | coverage-info))) 310 | 311 | ;; useful for debugging: 312 | ;; (setq tmp-input 313 | ;; '((:line-beg 3 :line-end 3 :status missing) 314 | ;; (:line-beg 4 :line-end 4 :status missing) 315 | ;; (:line-beg 5 :line-end 5 :status missing) 316 | ;; (:line-beg 8 :line-end 8 :status missing) 317 | ;; (:line-beg 10 :line-end 10 :status missing) 318 | ;; (:line-beg 11 :line-end 11 :status missing) 319 | ;; (:line-beg 12 :line-end 12 :status missing) 320 | ;; (:line-beg 13 :line-end 13 :status missing) 321 | ;; (:line-beg 15 :line-end 15 :status missing) 322 | ;; (:line-beg 16 :line-end 16 :status missing))) 323 | ;; (python-coverage--merge-adjacent tmp-input) 324 | 325 | 326 | ;; Internal helpers for overlays 327 | 328 | (defun python-coverage--overlay-make-all (coverage-info) 329 | "Create all overlays for COVERAGE-INFO." 330 | (let ((outdated? (python-coverage--coverage-file-outdated?))) 331 | (--each coverage-info 332 | (python-coverage--overlay-make it outdated?)))) 333 | 334 | (defun python-coverage--overlay-make (info outdated) 335 | "Make an overlay for coverage INFO. 336 | 337 | If OUTDATED is non-nil, use a different style." 338 | (save-restriction 339 | (widen) 340 | (-let* (((&plist :line-beg :line-end :status) info) 341 | (beg 342 | (save-excursion 343 | (goto-char (point-min)) 344 | (forward-line (1- line-beg)) 345 | (point))) 346 | (end 347 | (save-excursion 348 | (goto-char beg) 349 | (forward-line (- line-end line-beg)) 350 | (if python-coverage-overlay-width 351 | (min 352 | (line-end-position) 353 | (+ (point) python-coverage-overlay-width)) 354 | ;; End of statement for python 355 | ;; fallback to end of line for other languages 356 | (if (derived-mode-p 'python-mode 'python-ts-mode) 357 | (progn 358 | (python-nav-end-of-statement) 359 | (1+ (point))) 360 | (line-end-position))))) 361 | (end 362 | ;; At least one character. This should only happen for 363 | ;; outdated overlays on empty lines. 364 | (max end (1+ beg))) 365 | (face 366 | (pcase status 367 | ('missing 368 | (if outdated 369 | 'python-coverage-overlay-missing-outdated 370 | 'python-coverage-overlay-missing)) 371 | ('partial 372 | (if outdated 373 | 'python-coverage-overlay-partial-outdated 374 | 'python-coverage-overlay-partial)))) 375 | (overlay 376 | (-doto (make-overlay beg end) 377 | (overlay-put 'evaporate t) 378 | (overlay-put 'category 'python-coverage) 379 | (overlay-put 'face face)))) 380 | overlay))) 381 | 382 | (defun python-coverage--overlays-in (&optional beg end) 383 | "Return all overlays between BEG and END." 384 | (unless beg (setq beg (point-min))) 385 | (unless end (setq end (point-max))) 386 | (save-restriction 387 | (widen) 388 | (->> (overlays-in (point-min) (point-max)) 389 | (--filter (eq (overlay-get it 'category) 'python-coverage)) 390 | (--filter (>= (overlay-start it) beg)) 391 | (--filter (<= (overlay-end it) end)) 392 | (-sort (-on '< 'overlay-start))))) 393 | 394 | (defun python-coverage--overlay-add-watch () 395 | "Watch the coverage file to automatically refresh overlays." 396 | (let* ((coverage-file (python-coverage--find-coverage-file-current-buffer)) 397 | (watch 398 | (file-notify-add-watch 399 | coverage-file 400 | '(change attribute-change) 401 | (-partial 402 | 'python-coverage--overlay-watch-on-change 403 | (current-buffer))))) 404 | (setq python-coverage--overlay-watch watch))) 405 | 406 | (defun python-coverage--mark-as-outdated () 407 | "Mark all overlays as outdated." 408 | (--each (python-coverage--overlays-in) 409 | (let* ((face (overlay-get it 'face)) 410 | (new-face 411 | (pcase face 412 | ('python-coverage-overlay-missing 'python-coverage-overlay-missing-outdated) 413 | ('python-coverage-overlay-partial 'python-coverage-overlay-partial-outdated)))) 414 | (overlay-put it 'face new-face)))) 415 | 416 | (defun python-coverage--overlay-remove-watch () 417 | "Remove the file watch on the coverage file." 418 | (when (and python-coverage--overlay-watch 419 | (file-notify-valid-p python-coverage--overlay-watch)) 420 | (file-notify-rm-watch python-coverage--overlay-watch)) 421 | (setq python-coverage--overlay-watch nil)) 422 | 423 | (defun python-coverage--overlay-watch-on-change (buffer _event) 424 | "Change event handler for file watching. 425 | 426 | The EVENT causes the overlays in BUFFER to get refreshed." 427 | (when (buffer-live-p buffer) 428 | (with-current-buffer buffer 429 | (python-coverage-overlay-refresh)))) 430 | 431 | ;; Internal helpers for flycheck 432 | 433 | (declare-function flycheck-define-generic-checker "flycheck") 434 | (declare-function flycheck-error-new-at "flycheck") 435 | (declare-function flycheck-verification-result-new "flycheck") 436 | (defvar flycheck-checkers) 437 | 438 | (defun python-coverage--flycheck-predicate () 439 | "Check whether the flycheck checker can be used." 440 | (condition-case nil 441 | (python-coverage--find-coverage-file-current-buffer) 442 | (error nil))) 443 | 444 | (defun python-coverage--flycheck-verify (_checker) 445 | "Check whether the flycheck checker can be used." 446 | (list 447 | (condition-case err 448 | (let ((coverage-file (python-coverage--find-coverage-file-current-buffer))) 449 | (flycheck-verification-result-new 450 | :label "report" 451 | :message (format "Found at %s" coverage-file) 452 | :face 'success)) 453 | (error 454 | (flycheck-verification-result-new 455 | :label "report" 456 | :message (format "%s" (error-message-string err)) 457 | :face 'error))))) 458 | 459 | (defun python-coverage--flycheck-error (info checker) 460 | "Create a flycheck error for CHECKER containing INFO ." 461 | (-let* (((&plist :line-beg :status :missing-branches) info) 462 | (column 1) 463 | (level 464 | (pcase status 465 | ('missing 'error) 466 | ('partial 'warning))) 467 | (message 468 | (pcase status 469 | ('missing "Not covered") 470 | ('partial 471 | (s-concat "Partially covered" (-some->> missing-branches (format " (missing branches: %s)"))))))) 472 | (flycheck-error-new-at line-beg column level message :checker checker))) 473 | 474 | (defun python-coverage--flycheck-start (checker callback) 475 | "Flycheck start function for CHECKER, invoking CALLBACK." 476 | (condition-case err 477 | (let* ((context) 478 | (coverage-info (python-coverage-current-buffer)) 479 | (errors 480 | (--map 481 | (python-coverage--flycheck-error it checker) 482 | coverage-info))) 483 | (funcall callback 'finished errors) 484 | context) 485 | (error 486 | (funcall callback 'errored (error-message-string err)) 487 | (signal (car err) (cdr err))))) 488 | 489 | (with-eval-after-load 'flycheck 490 | (flycheck-define-generic-checker 'python-coverage 491 | "A Python checker to show coverage results using a XML report." 492 | :modes '(python-mode) 493 | :start #'python-coverage--flycheck-start 494 | :predicate #'python-coverage--flycheck-predicate 495 | :verify #'python-coverage--flycheck-verify) 496 | 497 | (add-to-list 'flycheck-checkers 'python-coverage t)) 498 | 499 | (provide 'python-coverage) 500 | ;;; python-coverage.el ends here 501 | --------------------------------------------------------------------------------