├── .eslintrc.js ├── .github └── workflows │ └── unit_testing.yml ├── .gitignore ├── LICENSE ├── README.md ├── action.yml ├── dist └── index.js ├── index.js ├── lint.js ├── package-lock.json ├── package.json ├── prepare-release.sh └── test ├── index.test.js └── notebooks ├── fib_clean.ipynb ├── fib_dirty.ipynb ├── fib_dirty_execution_count.ipynb └── fib_dirty_outputs.ipynb /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "env": { 3 | "node": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 2018, 13 | "sourceType": "module" 14 | }, 15 | "rules": { 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /.github/workflows/unit_testing.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [push] 4 | 5 | jobs: 6 | test: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | strategy: 11 | matrix: 12 | node-version: [12.x] 13 | 14 | steps: 15 | - uses: actions/checkout@v1 16 | - name: Setup Node ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | - name: npm install, build, and test 21 | run: | 22 | npm install 23 | # npm run build --if-present 24 | npm test 25 | # env: 26 | # CI: true 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | #dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and not Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Stores VSCode versions used for testing VSCode extensions 107 | .vscode-test 108 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 ResearchSoftwareActions 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Actions Status](https://github.com/ResearchSoftwareActions/CleanNotebookAction/workflows/tests/badge.svg)](https://github.com/ResearchSoftwareActions/CleanNotebookAction/actions) 2 | 3 | # Jupyter Notebook metadata enforcement GitHub action 4 | 5 | This GitHub action enforces rules on certain cells and metadata in Jupyter Notebooks. 6 | 7 | Because Jupyter notebooks contain metadata such as `outputs` and `execution_count`, they do not lend themselves well to versioning unless the unnecessary information has first been removed. 8 | This GitHub action can ensure that CI tests fail unless Jupyter notebooks have been appropriately linted prior to being pushed to the repository. 9 | 10 | ## Quickstart 11 | 12 | Add the following step to your GitHub action: 13 | 14 | ```yaml 15 | - uses: ResearchSoftwareActions/EnsureCleanNotebooksAction@1.1 16 | ``` 17 | 18 | ## Checks 19 | 20 | All of the following are checked by default: 21 | 22 | ### `outputs` 23 | 24 | The Jupyter `outputs` field is a list of outputs from each cell. 25 | These can include binary data. 26 | By default the action will fail if `outputs` is not an empty list (`[]`). 27 | 28 | ### `execution_count` 29 | 30 | The Jupyter `execution_count` field is an integer counting the number of cell executions. 31 | By default the action will fail if `execution_count` is not `null`. 32 | 33 | ## Configure checks 34 | 35 | This action takes one optional argument (`disable-checks`) that specifies which checks, supplied as a comma separated list, should be disabled. 36 | 37 | The full list of checks are: 38 | 39 | - `outputs` disable checks relating to cell outputs 40 | - `execution_count` disable checks relating to execution count 41 | 42 | ### Full example usage 43 | 44 | ```yaml 45 | - uses: ResearchSoftwareActions/EnsureCleanNotebooksAction@1.1 46 | with: 47 | disable-checks: outputs,execution_count 48 | ``` 49 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: 'Ensure Clean Jupyter Notebooks' 2 | description: 'Enforce rules on certain cells and metadata in Jupyter Notebooks' 3 | inputs: 4 | disable-checks: 5 | description: 'Optional list of linting checks to disable' 6 | required: false 7 | default: '' 8 | runs: 9 | using: 'node12' 10 | main: 'dist/index.js' 11 | branding: 12 | icon: 'alert-triangle' 13 | color: 'yellow' -------------------------------------------------------------------------------- /dist/index.js: -------------------------------------------------------------------------------- 1 | module.exports = 2 | /******/ (function(modules, runtime) { // webpackBootstrap 3 | /******/ "use strict"; 4 | /******/ // The module cache 5 | /******/ var installedModules = {}; 6 | /******/ 7 | /******/ // The require function 8 | /******/ function __webpack_require__(moduleId) { 9 | /******/ 10 | /******/ // Check if module is in cache 11 | /******/ if(installedModules[moduleId]) { 12 | /******/ return installedModules[moduleId].exports; 13 | /******/ } 14 | /******/ // Create a new module (and put it into the cache) 15 | /******/ var module = installedModules[moduleId] = { 16 | /******/ i: moduleId, 17 | /******/ l: false, 18 | /******/ exports: {} 19 | /******/ }; 20 | /******/ 21 | /******/ // Execute the module function 22 | /******/ modules[moduleId].call(module.exports, module, module.exports, __webpack_require__); 23 | /******/ 24 | /******/ // Flag the module as loaded 25 | /******/ module.l = true; 26 | /******/ 27 | /******/ // Return the exports of the module 28 | /******/ return module.exports; 29 | /******/ } 30 | /******/ 31 | /******/ 32 | /******/ __webpack_require__.ab = __dirname + "/"; 33 | /******/ 34 | /******/ // the startup function 35 | /******/ function startup() { 36 | /******/ // Load entry module and return exports 37 | /******/ return __webpack_require__(104); 38 | /******/ }; 39 | /******/ 40 | /******/ // run startup 41 | /******/ return startup(); 42 | /******/ }) 43 | /************************************************************************/ 44 | /******/ ({ 45 | 46 | /***/ 87: 47 | /***/ (function(module) { 48 | 49 | module.exports = require("os"); 50 | 51 | /***/ }), 52 | 53 | /***/ 104: 54 | /***/ (function(__unusedmodule, __unusedexports, __webpack_require__) { 55 | 56 | const core = __webpack_require__(470); 57 | const walk = __webpack_require__(191); 58 | const path = __webpack_require__(622); 59 | const lint = __webpack_require__(877); 60 | 61 | const disableChecks = core.getInput('disable-checks', {required: false}); 62 | 63 | let disabled = []; 64 | if (disableChecks) { 65 | disabled = disableChecks.split(','); 66 | } 67 | 68 | const walker = walk.walk(".", {followLinks: false, filters: ["node_modules"]}); 69 | const results = []; 70 | walker.on("file", function (root, fileStats, next) { 71 | if (path.extname(fileStats.name) === '.ipynb') { 72 | results.push(lint(path.join(root, fileStats.name), disabled)); 73 | } 74 | next(); 75 | }); 76 | 77 | walker.on("end", function () { 78 | if (!results.every(i => i)) { 79 | console.log(`${results.filter(v => !v).length}/${results.length} notebooks need cleaning!`); 80 | core.setFailed('Lint failed'); 81 | } else { 82 | console.log(`${results.length}/${results.length} notebooks are clean!`); 83 | } 84 | }); 85 | 86 | 87 | /***/ }), 88 | 89 | /***/ 191: 90 | /***/ (function(__unusedmodule, exports, __webpack_require__) { 91 | 92 | // Adapted from work by jorge@jorgechamorro.com on 2010-11-25 93 | (function () { 94 | "use strict"; 95 | 96 | function noop() {} 97 | 98 | var fs = __webpack_require__(747) 99 | , forEachAsync = __webpack_require__(724).forEachAsync 100 | , EventEmitter = __webpack_require__(614).EventEmitter 101 | , TypeEmitter = __webpack_require__(381) 102 | , util = __webpack_require__(669) 103 | , path = __webpack_require__(622) 104 | ; 105 | 106 | function appendToDirs(stat) { 107 | /*jshint validthis:true*/ 108 | if(stat.flag && stat.flag === NO_DESCEND) { return; } 109 | this.push(stat.name); 110 | } 111 | 112 | function wFilesHandlerWrapper(items) { 113 | /*jshint validthis:true*/ 114 | this._wFilesHandler(noop, items); 115 | } 116 | 117 | function Walker(pathname, options, sync) { 118 | EventEmitter.call(this); 119 | 120 | var me = this 121 | ; 122 | 123 | options = options || {}; 124 | me._wStat = options.followLinks && 'stat' || 'lstat'; 125 | me._wStatSync = me._wStat + 'Sync'; 126 | me._wsync = sync; 127 | me._wq = []; 128 | me._wqueue = [me._wq]; 129 | me._wcurpath = undefined; 130 | me._wfilters = options.filters || []; 131 | me._wfirstrun = true; 132 | me._wcurpath = pathname; 133 | 134 | if (me._wsync) { 135 | //console.log('_walkSync'); 136 | me._wWalk = me._wWalkSync; 137 | } else { 138 | //console.log('_walkASync'); 139 | me._wWalk = me._wWalkAsync; 140 | } 141 | 142 | options.listeners = options.listeners || {}; 143 | Object.keys(options.listeners).forEach(function (event) { 144 | var callbacks = options.listeners[event] 145 | ; 146 | 147 | if ('function' === typeof callbacks) { 148 | callbacks = [callbacks]; 149 | } 150 | 151 | callbacks.forEach(function (callback) { 152 | me.on(event, callback); 153 | }); 154 | }); 155 | 156 | me._wWalk(); 157 | } 158 | 159 | // Inherits must come before prototype additions 160 | util.inherits(Walker, EventEmitter); 161 | 162 | Walker.prototype._wLstatHandler = function (err, stat) { 163 | var me = this 164 | ; 165 | 166 | stat = stat || {}; 167 | stat.name = me._wcurfile; 168 | 169 | if (err) { 170 | stat.error = err; 171 | //me.emit('error', curpath, stat); 172 | // TODO v3.0 (don't noop the next if there are listeners) 173 | me.emit('nodeError', me._wcurpath, stat, noop); 174 | me._wfnodegroups.errors.push(stat); 175 | me._wCurFileCallback(); 176 | } else { 177 | TypeEmitter.sortFnodesByType(stat, me._wfnodegroups); 178 | // NOTE: wCurFileCallback doesn't need thisness, so this is okay 179 | TypeEmitter.emitNodeType(me, me._wcurpath, stat, me._wCurFileCallback, me); 180 | } 181 | }; 182 | Walker.prototype._wFilesHandler = function (cont, file) { 183 | var statPath 184 | , me = this 185 | ; 186 | 187 | 188 | me._wcurfile = file; 189 | me._wCurFileCallback = cont; 190 | me.emit('name', me._wcurpath, file, noop); 191 | 192 | statPath = me._wcurpath + path.sep + file; 193 | 194 | if (!me._wsync) { 195 | // TODO how to remove this anony? 196 | fs[me._wStat](statPath, function (err, stat) { 197 | me._wLstatHandler(err, stat); 198 | }); 199 | return; 200 | } 201 | 202 | try { 203 | me._wLstatHandler(null, fs[me._wStatSync](statPath)); 204 | } catch(e) { 205 | me._wLstatHandler(e); 206 | } 207 | }; 208 | Walker.prototype._wOnEmitDone = function () { 209 | var me = this 210 | , dirs = [] 211 | ; 212 | 213 | me._wfnodegroups.directories.forEach(appendToDirs, dirs); 214 | dirs.forEach(me._wJoinPath, me); 215 | me._wqueue.push(me._wq = dirs); 216 | me._wNext(); 217 | }; 218 | Walker.prototype._wPostFilesHandler = function () { 219 | var me = this 220 | ; 221 | 222 | if (me._wfnodegroups.errors.length) { 223 | // TODO v3.0 (don't noop the next) 224 | // .errors is an array of stats with { name: name, error: error } 225 | me.emit('errors', me._wcurpath, me._wfnodegroups.errors, noop); 226 | } 227 | // XXX emitNodeTypes still needs refactor 228 | TypeEmitter.emitNodeTypeGroups(me, me._wcurpath, me._wfnodegroups, me._wOnEmitDone, me); 229 | }; 230 | Walker.prototype._wReadFiles = function () { 231 | var me = this 232 | ; 233 | 234 | if (!me._wcurfiles || 0 === me._wcurfiles.length) { 235 | return me._wNext(); 236 | } 237 | 238 | // TODO could allow user to selectively stat 239 | // and don't stat if there are no stat listeners 240 | me.emit('names', me._wcurpath, me._wcurfiles, noop); 241 | 242 | if (me._wsync) { 243 | me._wcurfiles.forEach(wFilesHandlerWrapper, me); 244 | me._wPostFilesHandler(); 245 | } else { 246 | forEachAsync(me._wcurfiles, me._wFilesHandler, me).then(me._wPostFilesHandler); 247 | } 248 | }; 249 | Walker.prototype._wReaddirHandler = function (err, files) { 250 | var fnodeGroups = TypeEmitter.createNodeGroups() 251 | , me = this 252 | , parent 253 | , child 254 | ; 255 | 256 | me._wfnodegroups = fnodeGroups; 257 | me._wcurfiles = files; 258 | 259 | // no error, great 260 | if (!err) { 261 | me._wReadFiles(); 262 | return; 263 | } 264 | 265 | // TODO path.sep 266 | me._wcurpath = me._wcurpath.replace(/\/$/, ''); 267 | 268 | // error? not first run? => directory error 269 | if (!me._wfirstrun) { 270 | // TODO v3.0 (don't noop the next if there are listeners) 271 | me.emit('directoryError', me._wcurpath, { error: err }, noop); 272 | // TODO v3.0 273 | //me.emit('directoryError', me._wcurpath.replace(/^(.*)\/.*$/, '$1'), { name: me._wcurpath.replace(/^.*\/(.*)/, '$1'), error: err }, noop); 274 | me._wReadFiles(); 275 | return; 276 | } 277 | 278 | // error? first run? => maybe a file, maybe a true error 279 | me._wfirstrun = false; 280 | 281 | // readdir failed (might be a file), try a stat on the parent 282 | parent = me._wcurpath.replace(/^(.*)\/.*$/, '$1'); 283 | fs[me._wStat](parent, function (e, stat) { 284 | 285 | if (stat) { 286 | // success 287 | // now try stat on this as a child of the parent directory 288 | child = me._wcurpath.replace(/^.*\/(.*)$/, '$1'); 289 | me._wcurfiles = [child]; 290 | me._wcurpath = parent; 291 | } else { 292 | // TODO v3.0 293 | //me.emit('directoryError', me._wcurpath.replace(/^(.*)\/.*$/, '$1'), { name: me._wcurpath.replace(/^.*\/(.*)/, '$1'), error: err }, noop); 294 | // TODO v3.0 (don't noop the next) 295 | // the original readdir error, not the parent stat error 296 | me.emit('nodeError', me._wcurpath, { error: err }, noop); 297 | } 298 | 299 | me._wReadFiles(); 300 | }); 301 | }; 302 | Walker.prototype._wFilter = function () { 303 | var me = this 304 | , exclude 305 | ; 306 | 307 | // Stop directories that contain filter keywords 308 | // from continuing through the walk process 309 | exclude = me._wfilters.some(function (filter) { 310 | if (me._wcurpath.match(filter)) { 311 | return true; 312 | } 313 | }); 314 | 315 | return exclude; 316 | }; 317 | Walker.prototype._wWalkSync = function () { 318 | //console.log('walkSync'); 319 | var err 320 | , files 321 | , me = this 322 | ; 323 | 324 | try { 325 | files = fs.readdirSync(me._wcurpath); 326 | } catch(e) { 327 | err = e; 328 | } 329 | 330 | me._wReaddirHandler(err, files); 331 | }; 332 | Walker.prototype._wWalkAsync = function () { 333 | //console.log('walkAsync'); 334 | var me = this 335 | ; 336 | 337 | // TODO how to remove this anony? 338 | fs.readdir(me._wcurpath, function (err, files) { 339 | me._wReaddirHandler(err, files); 340 | }); 341 | }; 342 | Walker.prototype._wNext = function () { 343 | var me = this 344 | ; 345 | 346 | if (me._paused) { 347 | return; 348 | } 349 | if (me._wq.length) { 350 | me._wcurpath = me._wq.pop(); 351 | while (me._wq.length && me._wFilter()) { 352 | me._wcurpath = me._wq.pop(); 353 | } 354 | if (me._wcurpath && !me._wFilter()) { 355 | me._wWalk(); 356 | } else { 357 | me._wNext(); 358 | } 359 | return; 360 | } 361 | me._wqueue.length -= 1; 362 | if (me._wqueue.length) { 363 | me._wq = me._wqueue[me._wqueue.length - 1]; 364 | return me._wNext(); 365 | } 366 | 367 | // To not break compatibility 368 | //process.nextTick(function () { 369 | me.emit('end'); 370 | //}); 371 | }; 372 | Walker.prototype._wJoinPath = function (v, i, o) { 373 | var me = this 374 | ; 375 | 376 | o[i] = [me._wcurpath, path.sep, v].join(''); 377 | }; 378 | Walker.prototype.pause = function () { 379 | this._paused = true; 380 | }; 381 | Walker.prototype.resume = function () { 382 | this._paused = false; 383 | this._wNext(); 384 | }; 385 | 386 | exports.walk = function (path, opts) { 387 | return new Walker(path, opts, false); 388 | }; 389 | 390 | exports.walkSync = function (path, opts) { 391 | return new Walker(path, opts, true); 392 | }; 393 | }()); 394 | 395 | 396 | /***/ }), 397 | 398 | /***/ 381: 399 | /***/ (function(module) { 400 | 401 | /*jshint strict:true node:true es5:true onevar:true laxcomma:true laxbreak:true*/ 402 | (function () { 403 | "use strict"; 404 | 405 | // "FIFO" isn't easy to convert to camelCase and back reliably 406 | var isFnodeTypes = [ 407 | "isFile", "isDirectory", "isSymbolicLink", "isBlockDevice", "isCharacterDevice", "isFIFO", "isSocket" 408 | ], 409 | fnodeTypes = [ 410 | "file", "directory", "symbolicLink", "blockDevice", "characterDevice", "FIFO", "socket" 411 | ], 412 | fnodeTypesPlural = [ 413 | "files", "directories", "symbolicLinks", "blockDevices", "characterDevices", "FIFOs", "sockets" 414 | ]; 415 | 416 | 417 | // 418 | function createNodeGroups() { 419 | var nodeGroups = {}; 420 | fnodeTypesPlural.concat("nodes", "errors").forEach(function (fnodeTypePlural) { 421 | nodeGroups[fnodeTypePlural] = []; 422 | }); 423 | return nodeGroups; 424 | } 425 | 426 | 427 | // Determine each file node's type 428 | // 429 | function sortFnodesByType(stat, fnodes) { 430 | var i, isType; 431 | 432 | for (i = 0; i < isFnodeTypes.length; i += 1) { 433 | isType = isFnodeTypes[i]; 434 | if (stat[isType]()) { 435 | stat.type = fnodeTypes[i]; 436 | fnodes[fnodeTypesPlural[i]].push(stat); 437 | return; 438 | } 439 | } 440 | } 441 | 442 | 443 | // Get the current number of listeners (which may change) 444 | // Emit events to each listener 445 | // Wait for all listeners to `next()` before continueing 446 | // (in theory this may avoid disk thrashing) 447 | function emitSingleEvents(emitter, path, stats, next, self) { 448 | var num = 1 + emitter.listeners(stats.type).length + emitter.listeners("node").length; 449 | 450 | function nextWhenReady(flag) { 451 | if (flag) { 452 | stats.flag = flag; 453 | } 454 | num -= 1; 455 | if (0 === num) { next.call(self); } 456 | } 457 | 458 | emitter.emit(stats.type, path, stats, nextWhenReady); 459 | emitter.emit("node", path, stats, nextWhenReady); 460 | nextWhenReady(); 461 | } 462 | 463 | 464 | // Since the risk for disk thrashing among anything 465 | // other than files is relatively low, all types are 466 | // emitted at once, but all must complete before advancing 467 | function emitPluralEvents(emitter, path, nodes, next, self) { 468 | var num = 1; 469 | 470 | function nextWhenReady() { 471 | num -= 1; 472 | if (0 === num) { next.call(self); } 473 | } 474 | 475 | fnodeTypesPlural.concat(["nodes", "errors"]).forEach(function (fnodeType) { 476 | if (0 === nodes[fnodeType].length) { return; } 477 | num += emitter.listeners(fnodeType).length; 478 | emitter.emit(fnodeType, path, nodes[fnodeType], nextWhenReady); 479 | }); 480 | nextWhenReady(); 481 | } 482 | 483 | module.exports = { 484 | emitNodeType: emitSingleEvents, 485 | emitNodeTypeGroups: emitPluralEvents, 486 | isFnodeTypes: isFnodeTypes, 487 | fnodeTypes: fnodeTypes, 488 | fnodeTypesPlural: fnodeTypesPlural, 489 | sortFnodesByType: sortFnodesByType, 490 | createNodeGroups: createNodeGroups 491 | }; 492 | }()); 493 | 494 | 495 | /***/ }), 496 | 497 | /***/ 431: 498 | /***/ (function(__unusedmodule, exports, __webpack_require__) { 499 | 500 | "use strict"; 501 | 502 | Object.defineProperty(exports, "__esModule", { value: true }); 503 | const os = __webpack_require__(87); 504 | /** 505 | * Commands 506 | * 507 | * Command Format: 508 | * ##[name key=value;key=value]message 509 | * 510 | * Examples: 511 | * ##[warning]This is the user warning message 512 | * ##[set-secret name=mypassword]definitelyNotAPassword! 513 | */ 514 | function issueCommand(command, properties, message) { 515 | const cmd = new Command(command, properties, message); 516 | process.stdout.write(cmd.toString() + os.EOL); 517 | } 518 | exports.issueCommand = issueCommand; 519 | function issue(name, message = '') { 520 | issueCommand(name, {}, message); 521 | } 522 | exports.issue = issue; 523 | const CMD_STRING = '::'; 524 | class Command { 525 | constructor(command, properties, message) { 526 | if (!command) { 527 | command = 'missing.command'; 528 | } 529 | this.command = command; 530 | this.properties = properties; 531 | this.message = message; 532 | } 533 | toString() { 534 | let cmdStr = CMD_STRING + this.command; 535 | if (this.properties && Object.keys(this.properties).length > 0) { 536 | cmdStr += ' '; 537 | for (const key in this.properties) { 538 | if (this.properties.hasOwnProperty(key)) { 539 | const val = this.properties[key]; 540 | if (val) { 541 | // safely append the val - avoid blowing up when attempting to 542 | // call .replace() if message is not a string for some reason 543 | cmdStr += `${key}=${escape(`${val || ''}`)},`; 544 | } 545 | } 546 | } 547 | } 548 | cmdStr += CMD_STRING; 549 | // safely append the message - avoid blowing up when attempting to 550 | // call .replace() if message is not a string for some reason 551 | const message = `${this.message || ''}`; 552 | cmdStr += escapeData(message); 553 | return cmdStr; 554 | } 555 | } 556 | function escapeData(s) { 557 | return s.replace(/\r/g, '%0D').replace(/\n/g, '%0A'); 558 | } 559 | function escape(s) { 560 | return s 561 | .replace(/\r/g, '%0D') 562 | .replace(/\n/g, '%0A') 563 | .replace(/]/g, '%5D') 564 | .replace(/;/g, '%3B'); 565 | } 566 | //# sourceMappingURL=command.js.map 567 | 568 | /***/ }), 569 | 570 | /***/ 470: 571 | /***/ (function(__unusedmodule, exports, __webpack_require__) { 572 | 573 | "use strict"; 574 | 575 | var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) { 576 | function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); } 577 | return new (P || (P = Promise))(function (resolve, reject) { 578 | function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } } 579 | function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } } 580 | function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); } 581 | step((generator = generator.apply(thisArg, _arguments || [])).next()); 582 | }); 583 | }; 584 | Object.defineProperty(exports, "__esModule", { value: true }); 585 | const command_1 = __webpack_require__(431); 586 | const os = __webpack_require__(87); 587 | const path = __webpack_require__(622); 588 | /** 589 | * The code to exit an action 590 | */ 591 | var ExitCode; 592 | (function (ExitCode) { 593 | /** 594 | * A code indicating that the action was successful 595 | */ 596 | ExitCode[ExitCode["Success"] = 0] = "Success"; 597 | /** 598 | * A code indicating that the action was a failure 599 | */ 600 | ExitCode[ExitCode["Failure"] = 1] = "Failure"; 601 | })(ExitCode = exports.ExitCode || (exports.ExitCode = {})); 602 | //----------------------------------------------------------------------- 603 | // Variables 604 | //----------------------------------------------------------------------- 605 | /** 606 | * Sets env variable for this action and future actions in the job 607 | * @param name the name of the variable to set 608 | * @param val the value of the variable 609 | */ 610 | function exportVariable(name, val) { 611 | process.env[name] = val; 612 | command_1.issueCommand('set-env', { name }, val); 613 | } 614 | exports.exportVariable = exportVariable; 615 | /** 616 | * Registers a secret which will get masked from logs 617 | * @param secret value of the secret 618 | */ 619 | function setSecret(secret) { 620 | command_1.issueCommand('add-mask', {}, secret); 621 | } 622 | exports.setSecret = setSecret; 623 | /** 624 | * Prepends inputPath to the PATH (for this action and future actions) 625 | * @param inputPath 626 | */ 627 | function addPath(inputPath) { 628 | command_1.issueCommand('add-path', {}, inputPath); 629 | process.env['PATH'] = `${inputPath}${path.delimiter}${process.env['PATH']}`; 630 | } 631 | exports.addPath = addPath; 632 | /** 633 | * Gets the value of an input. The value is also trimmed. 634 | * 635 | * @param name name of the input to get 636 | * @param options optional. See InputOptions. 637 | * @returns string 638 | */ 639 | function getInput(name, options) { 640 | const val = process.env[`INPUT_${name.replace(/ /g, '_').toUpperCase()}`] || ''; 641 | if (options && options.required && !val) { 642 | throw new Error(`Input required and not supplied: ${name}`); 643 | } 644 | return val.trim(); 645 | } 646 | exports.getInput = getInput; 647 | /** 648 | * Sets the value of an output. 649 | * 650 | * @param name name of the output to set 651 | * @param value value to store 652 | */ 653 | function setOutput(name, value) { 654 | command_1.issueCommand('set-output', { name }, value); 655 | } 656 | exports.setOutput = setOutput; 657 | //----------------------------------------------------------------------- 658 | // Results 659 | //----------------------------------------------------------------------- 660 | /** 661 | * Sets the action status to failed. 662 | * When the action exits it will be with an exit code of 1 663 | * @param message add error issue message 664 | */ 665 | function setFailed(message) { 666 | process.exitCode = ExitCode.Failure; 667 | error(message); 668 | } 669 | exports.setFailed = setFailed; 670 | //----------------------------------------------------------------------- 671 | // Logging Commands 672 | //----------------------------------------------------------------------- 673 | /** 674 | * Writes debug message to user log 675 | * @param message debug message 676 | */ 677 | function debug(message) { 678 | command_1.issueCommand('debug', {}, message); 679 | } 680 | exports.debug = debug; 681 | /** 682 | * Adds an error issue 683 | * @param message error issue message 684 | */ 685 | function error(message) { 686 | command_1.issue('error', message); 687 | } 688 | exports.error = error; 689 | /** 690 | * Adds an warning issue 691 | * @param message warning issue message 692 | */ 693 | function warning(message) { 694 | command_1.issue('warning', message); 695 | } 696 | exports.warning = warning; 697 | /** 698 | * Writes info to log with console.log. 699 | * @param message info message 700 | */ 701 | function info(message) { 702 | process.stdout.write(message + os.EOL); 703 | } 704 | exports.info = info; 705 | /** 706 | * Begin an output group. 707 | * 708 | * Output until the next `groupEnd` will be foldable in this group 709 | * 710 | * @param name The name of the output group 711 | */ 712 | function startGroup(name) { 713 | command_1.issue('group', name); 714 | } 715 | exports.startGroup = startGroup; 716 | /** 717 | * End an output group. 718 | */ 719 | function endGroup() { 720 | command_1.issue('endgroup'); 721 | } 722 | exports.endGroup = endGroup; 723 | /** 724 | * Wrap an asynchronous function call in a group. 725 | * 726 | * Returns the same type as the function itself. 727 | * 728 | * @param name The name of the group 729 | * @param fn The function to wrap in the group 730 | */ 731 | function group(name, fn) { 732 | return __awaiter(this, void 0, void 0, function* () { 733 | startGroup(name); 734 | let result; 735 | try { 736 | result = yield fn(); 737 | } 738 | finally { 739 | endGroup(); 740 | } 741 | return result; 742 | }); 743 | } 744 | exports.group = group; 745 | //----------------------------------------------------------------------- 746 | // Wrapper action state 747 | //----------------------------------------------------------------------- 748 | /** 749 | * Saves state for current action, the state can only be retrieved by this action's post job execution. 750 | * 751 | * @param name name of the state to store 752 | * @param value value to store 753 | */ 754 | function saveState(name, value) { 755 | command_1.issueCommand('save-state', { name }, value); 756 | } 757 | exports.saveState = saveState; 758 | /** 759 | * Gets the value of an state set by this action's main execution. 760 | * 761 | * @param name name of the state to get 762 | * @returns string 763 | */ 764 | function getState(name) { 765 | return process.env[`STATE_${name}`] || ''; 766 | } 767 | exports.getState = getState; 768 | //# sourceMappingURL=core.js.map 769 | 770 | /***/ }), 771 | 772 | /***/ 614: 773 | /***/ (function(module) { 774 | 775 | module.exports = require("events"); 776 | 777 | /***/ }), 778 | 779 | /***/ 622: 780 | /***/ (function(module) { 781 | 782 | module.exports = require("path"); 783 | 784 | /***/ }), 785 | 786 | /***/ 669: 787 | /***/ (function(module) { 788 | 789 | module.exports = require("util"); 790 | 791 | /***/ }), 792 | 793 | /***/ 724: 794 | /***/ (function(__unusedmodule, exports) { 795 | 796 | /*jshint -W054 */ 797 | ;(function (exports) { 798 | 'use strict'; 799 | 800 | function forEachAsync(arr, fn, thisArg) { 801 | var dones = [] 802 | , index = -1 803 | ; 804 | 805 | function next(BREAK, result) { 806 | index += 1; 807 | 808 | if (index === arr.length || BREAK === forEachAsync.__BREAK) { 809 | dones.forEach(function (done) { 810 | done.call(thisArg, result); 811 | }); 812 | return; 813 | } 814 | 815 | fn.call(thisArg, next, arr[index], index, arr); 816 | } 817 | 818 | setTimeout(next, 4); 819 | 820 | return { 821 | then: function (_done) { 822 | dones.push(_done); 823 | return this; 824 | } 825 | }; 826 | } 827 | forEachAsync.__BREAK = {}; 828 | 829 | exports.forEachAsync = forEachAsync; 830 | }( true && exports || new Function('return this')())); 831 | 832 | 833 | /***/ }), 834 | 835 | /***/ 747: 836 | /***/ (function(module) { 837 | 838 | module.exports = require("fs"); 839 | 840 | /***/ }), 841 | 842 | /***/ 877: 843 | /***/ (function(module, __unusedexports, __webpack_require__) { 844 | 845 | const fs = __webpack_require__(747); 846 | 847 | function lint(filename, disabled = []) { 848 | 849 | const json = JSON.parse(fs.readFileSync(filename, 'utf8')); 850 | 851 | let fail_outputs = false; 852 | let fail_execution_count = false; 853 | 854 | for (let i = 0; i < json.cells.length; ++i) { 855 | 856 | const cell = json.cells[i]; 857 | 858 | if (!fail_outputs && !disabled.includes('outputs') && has_key(cell, 'outputs')) { 859 | if (Array.from(cell['outputs']).length > 0) { 860 | fail_outputs = true; 861 | } 862 | } 863 | 864 | if (!fail_execution_count && !disabled.includes('execution_count') && has_key(cell, 'execution_count')) { 865 | if (cell['execution_count'] != null) { 866 | fail_execution_count = true; 867 | } 868 | } 869 | } 870 | 871 | // Warn users about which failures are present in this file 872 | if (fail_outputs) { 873 | console.log(`${filename}: nonempty outputs found`); 874 | } 875 | if (fail_execution_count) { 876 | console.log(`${filename}: non-null execution count found`); 877 | } 878 | 879 | return !(fail_outputs || fail_execution_count); 880 | } 881 | 882 | function has_key(obj, key) { 883 | return Object.prototype.hasOwnProperty.call(obj, key) 884 | } 885 | 886 | module.exports = lint; 887 | 888 | 889 | /***/ }) 890 | 891 | /******/ }); -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const core = require('@actions/core'); 2 | const walk = require('walk'); 3 | const path = require('path'); 4 | const lint = require('./lint'); 5 | 6 | const disableChecks = core.getInput('disable-checks', {required: false}); 7 | 8 | let disabled = []; 9 | if (disableChecks) { 10 | disabled = disableChecks.split(','); 11 | } 12 | 13 | const walker = walk.walk(".", {followLinks: false, filters: ["node_modules"]}); 14 | const results = []; 15 | walker.on("file", function (root, fileStats, next) { 16 | if (path.extname(fileStats.name) === '.ipynb') { 17 | results.push(lint(path.join(root, fileStats.name), disabled)); 18 | } 19 | next(); 20 | }); 21 | 22 | walker.on("end", function () { 23 | if (!results.every(i => i)) { 24 | console.log(`${results.filter(v => !v).length}/${results.length} notebooks need cleaning!`); 25 | core.setFailed('Lint failed'); 26 | } else { 27 | console.log(`${results.length}/${results.length} notebooks are clean!`); 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /lint.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | 3 | function lint(filename, disabled = []) { 4 | 5 | const json = JSON.parse(fs.readFileSync(filename, 'utf8')); 6 | 7 | let fail_outputs = false; 8 | let fail_execution_count = false; 9 | 10 | for (let i = 0; i < json.cells.length; ++i) { 11 | 12 | const cell = json.cells[i]; 13 | 14 | if (!fail_outputs && !disabled.includes('outputs') && has_key(cell, 'outputs')) { 15 | if (Array.from(cell['outputs']).length > 0) { 16 | fail_outputs = true; 17 | } 18 | } 19 | 20 | if (!fail_execution_count && !disabled.includes('execution_count') && has_key(cell, 'execution_count')) { 21 | if (cell['execution_count'] != null) { 22 | fail_execution_count = true; 23 | } 24 | } 25 | } 26 | 27 | // Warn users about which failures are present in this file 28 | if (fail_outputs) { 29 | console.log(`${filename}: nonempty outputs found`); 30 | } 31 | if (fail_execution_count) { 32 | console.log(`${filename}: non-null execution count found`); 33 | } 34 | 35 | return !(fail_outputs || fail_execution_count); 36 | } 37 | 38 | function has_key(obj, key) { 39 | return Object.prototype.hasOwnProperty.call(obj, key) 40 | } 41 | 42 | module.exports = lint; 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "CleanNotebookAction", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "eslint index.js lint.js && jest", 8 | "package": "ncc build index.js -o dist" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/ResearchSoftwareActions/CleanNotebookAction.git" 13 | }, 14 | "keywords": [], 15 | "author": "", 16 | "license": "ISC", 17 | "bugs": { 18 | "url": "https://github.com/ResearchSoftwareActions/CleanNotebookAction/issues" 19 | }, 20 | "homepage": "https://github.com/ResearchSoftwareActions/CleanNotebookAction#readme", 21 | "dependencies": { 22 | "@actions/core": "^1.2.6", 23 | "@actions/github": "^2.0.0", 24 | "walk": "^2.3.14" 25 | }, 26 | "devDependencies": { 27 | "@zeit/ncc": "^0.21.0", 28 | "eslint": "^6.8.0", 29 | "jest": "^24.9.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /prepare-release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npm run package 3 | git add dist/index.js 4 | git commit dist/index.js -m rebuild 5 | git push 6 | git checkout master 7 | git merge dev 8 | git push 9 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | const lint = require('../lint'); 2 | 3 | // Testing an unclean file with no tests disabled should return false 4 | test('unclean file', async () => { 5 | await expect(lint('./test/notebooks/fib_dirty.ipynb')).toBe(false); 6 | }); 7 | 8 | // Testing a clean file should always return true 9 | test('clean file', async () => { 10 | await expect(lint('./test/notebooks/fib_clean.ipynb')).toBe(true); 11 | }); 12 | 13 | // Testing an unclean file with all all tests disabled should return true 14 | test('dirty file with tests disabled', async () => { 15 | await expect(lint('./test/notebooks/fib_dirty.ipynb', ['outputs', 'execution_count'])).toBe(true); 16 | }); 17 | 18 | // Testing a file with unclean outputs, with and without `outputs` disabled 19 | test('dirty outputs with tests enabled', async () => { 20 | await expect(lint('./test/notebooks/fib_dirty_outputs.ipynb')).toBe(false); 21 | }); 22 | test('dirty outputs with tests disabled', async () => { 23 | await expect(lint('./test/notebooks/fib_dirty_outputs.ipynb', ['outputs'])).toBe(true); 24 | }); 25 | 26 | // Testing a file with unclean execution counts, with and without `execution_count` disabled 27 | test('dirty execution counts with tests enabled', async () => { 28 | await expect(lint('./test/notebooks/fib_dirty_execution_count.ipynb')).toBe(false); 29 | }); 30 | test('dirty execution counts with tests disabled', async () => { 31 | await expect(lint('./test/notebooks/fib_dirty_execution_count.ipynb', ['execution_count'])).toBe(true); 32 | }); 33 | -------------------------------------------------------------------------------- /test/notebooks/fib_clean.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "def fibonacci(n):\n", 10 | " a, b = 1, 0\n", 11 | " for i in range(n):\n", 12 | " a, b = b, a+b\n", 13 | " print(b)" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": null, 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "fibonacci(10)" 23 | ] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.7.5" 43 | } 44 | }, 45 | "nbformat": 4, 46 | "nbformat_minor": 2 47 | } 48 | -------------------------------------------------------------------------------- /test/notebooks/fib_dirty.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 5, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "def fibonacci(n):\n", 10 | " a, b = 1, 0\n", 11 | " for i in range(n):\n", 12 | " a, b = b, a+b\n", 13 | " print(b)" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 6, 19 | "metadata": {}, 20 | "outputs": [ 21 | { 22 | "name": "stdout", 23 | "output_type": "stream", 24 | "text": [ 25 | "1\n", 26 | "1\n", 27 | "2\n", 28 | "3\n", 29 | "5\n", 30 | "8\n", 31 | "13\n", 32 | "21\n", 33 | "34\n", 34 | "55\n" 35 | ] 36 | } 37 | ], 38 | "source": [ 39 | "fibonacci(10)" 40 | ] 41 | } 42 | ], 43 | "metadata": { 44 | "kernelspec": { 45 | "display_name": "Python 3", 46 | "language": "python", 47 | "name": "python3" 48 | }, 49 | "language_info": { 50 | "codemirror_mode": { 51 | "name": "ipython", 52 | "version": 3 53 | }, 54 | "file_extension": ".py", 55 | "mimetype": "text/x-python", 56 | "name": "python", 57 | "nbconvert_exporter": "python", 58 | "pygments_lexer": "ipython3", 59 | "version": "3.7.5" 60 | } 61 | }, 62 | "nbformat": 4, 63 | "nbformat_minor": 2 64 | } 65 | -------------------------------------------------------------------------------- /test/notebooks/fib_dirty_execution_count.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": 2, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "def fibonacci(n):\n", 10 | " a, b = 1, 0\n", 11 | " for i in range(n):\n", 12 | " a, b = b, a+b\n", 13 | " print(b)" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": 5, 19 | "metadata": {}, 20 | "outputs": [], 21 | "source": [ 22 | "fibonacci(10)" 23 | ] 24 | } 25 | ], 26 | "metadata": { 27 | "kernelspec": { 28 | "display_name": "Python 3", 29 | "language": "python", 30 | "name": "python3" 31 | }, 32 | "language_info": { 33 | "codemirror_mode": { 34 | "name": "ipython", 35 | "version": 3 36 | }, 37 | "file_extension": ".py", 38 | "mimetype": "text/x-python", 39 | "name": "python", 40 | "nbconvert_exporter": "python", 41 | "pygments_lexer": "ipython3", 42 | "version": "3.7.5" 43 | } 44 | }, 45 | "nbformat": 4, 46 | "nbformat_minor": 2 47 | } 48 | -------------------------------------------------------------------------------- /test/notebooks/fib_dirty_outputs.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "code", 5 | "execution_count": null, 6 | "metadata": {}, 7 | "outputs": [], 8 | "source": [ 9 | "def fibonacci(n):\n", 10 | " a, b = 1, 0\n", 11 | " for i in range(n):\n", 12 | " a, b = b, a+b\n", 13 | " print(b)" 14 | ] 15 | }, 16 | { 17 | "cell_type": "code", 18 | "execution_count": null, 19 | "metadata": {}, 20 | "outputs": [ 21 | { 22 | "name": "stdout", 23 | "output_type": "stream", 24 | "text": [ 25 | "1\n", 26 | "1\n", 27 | "2\n", 28 | "3\n", 29 | "5\n", 30 | "8\n", 31 | "13\n", 32 | "21\n", 33 | "34\n", 34 | "55\n" 35 | ] 36 | } 37 | ], 38 | "source": [ 39 | "fibonacci(10)" 40 | ] 41 | } 42 | ], 43 | "metadata": { 44 | "kernelspec": { 45 | "display_name": "Python 3", 46 | "language": "python", 47 | "name": "python3" 48 | }, 49 | "language_info": { 50 | "codemirror_mode": { 51 | "name": "ipython", 52 | "version": 3 53 | }, 54 | "file_extension": ".py", 55 | "mimetype": "text/x-python", 56 | "name": "python", 57 | "nbconvert_exporter": "python", 58 | "pygments_lexer": "ipython3", 59 | "version": "3.7.5" 60 | } 61 | }, 62 | "nbformat": 4, 63 | "nbformat_minor": 2 64 | } 65 | --------------------------------------------------------------------------------