footfall 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: cd91000a047937d1598600d2b06cf063db586d468841895158055b2d7a520f7f
4
+ data.tar.gz: 6e5615676b235a42b9ca63bc9bb3d801e710c4e821c8dfe31424c5f0c199928a
5
+ SHA512:
6
+ metadata.gz: 577732a8ccaad0e9d2a942a3e6f2cf7866399e2381fc3ce48d256d9eb3be5a24a3bcf2b1bb68d52979137f9294693006fe8fd0e2c3fdb1d10419590329503958
7
+ data.tar.gz: cebb8fd907bb3f3556dfcebc0000781ccb2f3a226e9c5def5722e007f0ea451feda02700e8c2f31587dd37129baa0f43bd4e3bbfb969d8ee2e8410767af44e61
data/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-10-09
4
+
5
+ - First version, extracted from mini-audition.com's own counter: the middleware, the
6
+ SQLite schema and the terminal report. Options for where downloads live, the site's
7
+ host, a proxy's client address header and the bot pattern; the report's download columns
8
+ and feed pattern are configurable. It never changes a table it didn't make: give it a
9
+ file of its own.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Oleh Khomei
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.
data/README.md ADDED
@@ -0,0 +1,102 @@
1
+ # footfall
2
+
3
+ Cookieless visitor, page view and download counts for a Rack app, in a SQLite file. Footfall
4
+ is what a shop counts at its door: people coming in, not who they are.
5
+
6
+ - **Pages, visitors, referrers and downloads.** A page view is a GET of HTML (or an Atom or
7
+ RSS feed) answered with 200; a download, a GET under `/download/` that sends the whole
8
+ file, counted once even when it's resumed in pieces.
9
+ - **Nobody can be picked out.** A visitor is a hash of their address and browser, salted
10
+ anew every day; the salt is forgotten the next day, so a day's visitors can be counted but
11
+ not followed from day to day, or traced back. No cookies, no IP addresses, and only the
12
+ site a visitor came from, not the full referrer.
13
+ - **Bots left out:** crawlers, link previews, monitors and command-line tools.
14
+ - **One file, no service.** SQLite in WAL mode, next to your app; a report for the terminal.
15
+ - **Never breaks a page.** A counting error is logged and the response goes out as it was.
16
+
17
+ ## Install
18
+
19
+ ```ruby
20
+ # Gemfile
21
+ gem "footfall"
22
+ ```
23
+
24
+ ## Count
25
+
26
+ ```ruby
27
+ # config.ru
28
+ require "footfall"
29
+
30
+ # Outermost, so it sees every response, downloads too. Without a path it counts nothing,
31
+ # which suits development and tests.
32
+ use Footfall::Middleware, path: ENV["FOOTFALL_PATH"]
33
+ ```
34
+
35
+ Options:
36
+
37
+ | Option | Default | |
38
+ |---|---|---|
39
+ | `path:` | (required) | The SQLite file, made with its folder; `nil` turns counting off. |
40
+ | `downloads:` | `"/download/"` | The path prefix of files counted as downloads; `nil` for none. |
41
+ | `host:` | the request's host | This site, whose own links aren't referrers (`www.` is ignored). |
42
+ | `client_ip_header:` | none | The header a proxy puts the visitor's address in, such as Cloudflare's `"CF-Connecting-IP"`. Only behind such a proxy: anyone can send it. |
43
+ | `bots:` | `Footfall::Middleware::BOTS` | A regexp of user agents left out. |
44
+
45
+ In Rails, `config.middleware.insert_before 0, Footfall::Middleware, path: …`.
46
+
47
+ ## Report
48
+
49
+ ```ruby
50
+ require "footfall"
51
+
52
+ Footfall::Report.new("storage/footfall.sqlite3",
53
+ title: "example.com",
54
+ downloads: { "App downloads" => "%.dmg", "Plugin installs" => "Plugin-%" }
55
+ ).print(days: 30)
56
+ ```
57
+
58
+ ```
59
+ example.com, the last 30 days (since 2026-09-10); bots left out
60
+
61
+ Totals
62
+ Visitors (a day each) Page views App downloads Plugin installs
63
+ --------------------- ---------- ------------- ---------------
64
+ 412 903 57 12
65
+
66
+ By day
67
+ Day Visitors Views App downloads
68
+ …
69
+ ```
70
+
71
+ Then the busiest pages, the sites visitors came from, every download, and the feed's
72
+ readers on the latest day. `downloads:` names a column in the totals per kind of file, as
73
+ SQL `LIKE` patterns of the file names; the first is also the day by day column. Feeds
74
+ (`feeds:`, `"%/feed.xml"` by default) are counted apart from pages, since readers poll them.
75
+
76
+ A small script makes it a command, for example for `kamal app exec`:
77
+
78
+ ```ruby
79
+ #!/usr/bin/env ruby
80
+ require "bundler/setup"
81
+ require "footfall"
82
+
83
+ Footfall::Report.new(ENV.fetch("FOOTFALL_PATH"), title: "example.com")
84
+ .print(days: Integer(ARGV.first || 30))
85
+ ```
86
+
87
+ ## The data
88
+
89
+ Three tables, for queries of your own:
90
+
91
+ - `views`: `day`, `path`, `referrer` (a host, or `""`), `visitor`, `count`, one row per day,
92
+ page, referrer and visitor;
93
+ - `downloads`: `day`, `file`, `visitor`, `count`;
94
+ - `salts`: today's salt only.
95
+
96
+ Footfall only creates tables that aren't there and never alters existing ones; give it a
97
+ file of its own. Days are in the server's time zone. Visitors are only comparable within a day: count
98
+ `DISTINCT day || visitor` for "visitors, a day each".
99
+
100
+ ## License
101
+
102
+ MIT
@@ -0,0 +1,32 @@
1
+ require "fileutils"
2
+ require "sqlite3"
3
+
4
+ module Footfall
5
+ # The SQLite file: a row per day, page, referring site and visitor for page views, and per
6
+ # day, file and visitor for downloads, each with how many times; and the day's salt.
7
+ module Database
8
+ SCHEMA = <<~SQL
9
+ CREATE TABLE IF NOT EXISTS salts (day TEXT PRIMARY KEY, salt TEXT NOT NULL);
10
+ CREATE TABLE IF NOT EXISTS views (
11
+ day TEXT NOT NULL, path TEXT NOT NULL, referrer TEXT NOT NULL,
12
+ visitor TEXT NOT NULL, count INTEGER NOT NULL DEFAULT 1,
13
+ PRIMARY KEY (day, path, referrer, visitor));
14
+ CREATE TABLE IF NOT EXISTS downloads (
15
+ day TEXT NOT NULL, file TEXT NOT NULL, visitor TEXT NOT NULL,
16
+ count INTEGER NOT NULL DEFAULT 1,
17
+ PRIMARY KEY (day, file, visitor));
18
+ SQL
19
+
20
+ # For counting: made (with its folder) if it isn't there yet. Readonly for reports.
21
+ def self.open(path, readonly: false)
22
+ return SQLite3::Database.new(path, readonly: true) if readonly
23
+
24
+ FileUtils.mkdir_p(File.dirname(path))
25
+ db = SQLite3::Database.new(path)
26
+ db.busy_timeout = 2_000
27
+ db.execute("PRAGMA journal_mode = WAL")
28
+ db.execute_batch(SCHEMA)
29
+ db
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,118 @@
1
+ require "date"
2
+ require "digest"
3
+ require "rack"
4
+ require "securerandom"
5
+ require "uri"
6
+
7
+ module Footfall
8
+ # Counts page views and downloads, after the app has answered. A page view is a GET of
9
+ # a page (HTML, or an Atom or RSS feed) answered with 200; a download, a GET under
10
+ # `downloads` that sends the whole file (200, or 206 from its first byte), so redirects
11
+ # and the later pieces of a resumed download aren't counted again.
12
+ #
13
+ # Nothing that identifies anyone is kept: a visitor is a hash of their IP address and
14
+ # browser with a salt that's new every day and forgotten the next. No cookies, no
15
+ # addresses, no full referrers (only the site a visitor came from). Bots are left out.
16
+ class Middleware
17
+ # Crawlers, link previews, monitors and command-line tools.
18
+ BOTS = /bot|crawl|spider|slurp|preview|monitor|curl|wget|python|ruby|go-http|java|
19
+ httpclient|headless|lighthouse|facebookexternalhit|embedly|whatsapp|telegram/xi
20
+
21
+ PAGES = %r{\A(text/html|application/(atom|rss)\+xml)\b}
22
+
23
+ # `path`: the SQLite file; nil turns counting off.
24
+ # `downloads`: the path prefix of files to count as downloads; nil for none.
25
+ # `host`: this site, whose own links aren't counted as referrers (the request's host
26
+ # unless given).
27
+ # `client_ip_header`: the header a proxy in front puts the visitor's address in, such
28
+ # as Cloudflare's "CF-Connecting-IP"; without one, the request's own address. Only
29
+ # set it behind such a proxy: anyone can send the header (it only skews the counts).
30
+ def initialize(app, path:, downloads: "/download/", host: nil, client_ip_header: nil,
31
+ bots: BOTS, today: -> { Date.today })
32
+ @app = app
33
+ @downloads = downloads
34
+ @host = host && site(host)
35
+ @client_ip = client_ip_header && "HTTP_#{client_ip_header.upcase.tr("-", "_")}"
36
+ @bots = bots
37
+ @today = today
38
+ @lock = Mutex.new
39
+ @db = path && Database.open(path)
40
+ end
41
+
42
+ def call(env)
43
+ status, headers, body = @app.call(env)
44
+ record(Rack::Request.new(env), status, headers) if @db
45
+ [ status, headers, body ]
46
+ end
47
+
48
+ private
49
+
50
+ # Never lets counting break a page.
51
+ def record(request, status, headers)
52
+ return unless request.get?
53
+ agent = request.user_agent.to_s
54
+ return if agent.empty? || @bots.match?(agent)
55
+
56
+ path = request.path_info
57
+ if @downloads && path.start_with?(@downloads)
58
+ download(request, path, status)
59
+ elsif status == 200 && PAGES.match?(headers["content-type"].to_s)
60
+ upsert("views", day: today, path: path, referrer: referrer(request),
61
+ visitor: visitor(request))
62
+ end
63
+ rescue StandardError => error
64
+ warn "footfall: #{error.class}: #{error.message}"
65
+ end
66
+
67
+ def download(request, path, status)
68
+ range = request.get_header("HTTP_RANGE").to_s
69
+ return unless status == 200 || (status == 206 && range.match?(/\Abytes=0-/))
70
+
71
+ upsert("downloads", day: today, file: File.basename(path), visitor: visitor(request))
72
+ end
73
+
74
+ # The site a visitor came from ("news.ycombinator.com"), or "" for none or this one.
75
+ def referrer(request)
76
+ from = site(URI.parse(request.referer.to_s).host.to_s)
77
+ from == (@host || site(request.host.to_s)) ? "" : from
78
+ rescue URI::InvalidURIError
79
+ ""
80
+ end
81
+
82
+ def site(host) = host.downcase.delete_prefix("www.")
83
+
84
+ def visitor(request)
85
+ address = (@client_ip && request.get_header(@client_ip)) || request.ip
86
+ Digest::SHA256.hexdigest("#{salt}|#{address}|#{request.user_agent}")[0, 16]
87
+ end
88
+
89
+ def today = @today.call.iso8601
90
+
91
+ # Today's salt, made the first time it's needed; the days before are forgotten then.
92
+ def salt
93
+ day = today
94
+ return @salt if @salt_day == day
95
+
96
+ @lock.synchronize do
97
+ @db.execute("DELETE FROM salts WHERE day < ?", [ day ])
98
+ @db.execute("INSERT OR IGNORE INTO salts (day, salt) VALUES (?, ?)",
99
+ [ day, SecureRandom.hex(16) ])
100
+ @salt = @db.get_first_value("SELECT salt FROM salts WHERE day = ?", [ day ])
101
+ @salt_day = day
102
+ end
103
+ @salt
104
+ end
105
+
106
+ def upsert(table, values)
107
+ # As text: Rack's paths and headers can be binary strings, which SQLite would keep
108
+ # as blobs, never equal to text in a query.
109
+ values = values.transform_values { |value| value.to_s.dup.force_encoding(Encoding::UTF_8).scrub }
110
+ marks = ([ "?" ] * values.size).join(", ")
111
+ @lock.synchronize do
112
+ @db.execute(
113
+ "INSERT INTO #{table} (#{values.keys.join(", ")}) VALUES (#{marks}) " \
114
+ "ON CONFLICT DO UPDATE SET count = count + 1", values.values)
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,104 @@
1
+ require "date"
2
+
3
+ module Footfall
4
+ # The counts in the terminal: totals, the last two weeks day by day, the busiest pages,
5
+ # the sites visitors came from, every download, and feed readers. Feeds are counted
6
+ # apart from pages, since readers poll them.
7
+ #
8
+ # Footfall::Report.new("stats.sqlite3", title: "example.com",
9
+ # downloads: { "App" => "%.dmg", "Plugins" => "%.zip" }).print(days: 30)
10
+ class Report
11
+ # `downloads`: a column in the totals per kind of file, as SQL LIKE patterns of their
12
+ # names; the first is also the day by day column.
13
+ # `feeds`: a LIKE pattern of the feeds' paths.
14
+ def initialize(path, title: nil, downloads: { "Downloads" => "%" }, feeds: "%/feed.xml",
15
+ io: $stdout)
16
+ @db = Database.open(path, readonly: true)
17
+ @title = title
18
+ @downloads = downloads
19
+ @feeds = feeds
20
+ @io = io
21
+ end
22
+
23
+ def print(days: 30, today: Date.today)
24
+ since = (today - days + 1).iso8601
25
+ @io.puts [ @title, "the last #{days} days (since #{since}); bots left out" ].compact.join(", ")
26
+ totals(since)
27
+ by_day(since)
28
+ pages(since)
29
+ referrers(since)
30
+ downloads
31
+ @io.puts "\nFeed readers (the latest day): #{feed_readers || 0}"
32
+ end
33
+
34
+ private
35
+
36
+ def totals(since)
37
+ visitors, views = @db.get_first_row(<<~SQL, [ since, @feeds ])
38
+ SELECT COUNT(DISTINCT day || visitor), COALESCE(SUM(count), 0)
39
+ FROM views WHERE day >= ? AND path NOT LIKE ?
40
+ SQL
41
+ files = @downloads.values.map do |pattern|
42
+ @db.get_first_value(
43
+ "SELECT COUNT(*) FROM downloads WHERE day >= ? AND file LIKE ?", [ since, pattern ])
44
+ end
45
+ table("Totals", [ [ visitors, views, *files ] ],
46
+ [ "Visitors (a day each)", "Page views", *@downloads.keys ])
47
+ end
48
+
49
+ def by_day(since)
50
+ rows = @db.execute(<<~SQL, [ @downloads.values.first, since, @feeds ])
51
+ SELECT day, COUNT(DISTINCT visitor), SUM(count),
52
+ (SELECT COUNT(*) FROM downloads d WHERE d.day = views.day AND d.file LIKE ?)
53
+ FROM views WHERE day >= ? AND path NOT LIKE ?
54
+ GROUP BY day ORDER BY day DESC LIMIT 14
55
+ SQL
56
+ table("By day", rows, [ "Day", "Visitors", "Views", @downloads.keys.first ])
57
+ end
58
+
59
+ def pages(since)
60
+ rows = @db.execute(<<~SQL, [ since, @feeds ])
61
+ SELECT path, COUNT(DISTINCT day || visitor), SUM(count)
62
+ FROM views WHERE day >= ? AND path NOT LIKE ?
63
+ GROUP BY path ORDER BY 2 DESC LIMIT 15
64
+ SQL
65
+ table("Pages", rows, [ "Page", "Visitors", "Views" ])
66
+ end
67
+
68
+ def referrers(since)
69
+ rows = @db.execute(<<~SQL, [ since, @feeds ])
70
+ SELECT referrer, COUNT(DISTINCT day || visitor)
71
+ FROM views WHERE day >= ? AND referrer != '' AND path NOT LIKE ?
72
+ GROUP BY referrer ORDER BY 2 DESC LIMIT 15
73
+ SQL
74
+ table("Where they came from", rows, [ "Site", "Visitors" ])
75
+ end
76
+
77
+ def downloads
78
+ rows = @db.execute(<<~SQL)
79
+ SELECT file, COUNT(*), MAX(day) FROM downloads GROUP BY file ORDER BY MAX(day) DESC, file
80
+ SQL
81
+ table("Downloads (all time)", rows, [ "File", "Downloads", "Last" ])
82
+ end
83
+
84
+ def feed_readers
85
+ @db.get_first_value(<<~SQL, [ @feeds, @feeds ])
86
+ SELECT COUNT(DISTINCT visitor) FROM views
87
+ WHERE path LIKE ? AND day = (SELECT MAX(day) FROM views WHERE path LIKE ?)
88
+ SQL
89
+ end
90
+
91
+ def table(title, rows, headers)
92
+ @io.puts "\n#{title}"
93
+ return @io.puts(" (none)") if rows.empty?
94
+
95
+ all = [ headers ] + rows.map { |row| row.map(&:to_s) }
96
+ widths = headers.each_index.map { |i| all.map { |row| row[i].length }.max }
97
+ all.each_with_index do |row, index|
98
+ cells = row.each_with_index.map { |cell, i| i.zero? ? cell.ljust(widths[i]) : cell.rjust(widths[i]) }
99
+ @io.puts " #{cells.join(" ")}"
100
+ @io.puts " #{widths.map { |width| "-" * width }.join(" ")}" if index.zero?
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,3 @@
1
+ module Footfall
2
+ VERSION = "0.1.0"
3
+ end
data/lib/footfall.rb ADDED
@@ -0,0 +1,9 @@
1
+ require "footfall/version"
2
+ require "footfall/database"
3
+ require "footfall/middleware"
4
+ require "footfall/report"
5
+
6
+ # Cookieless visitor, page view and download counts for a Rack app, in a SQLite file:
7
+ # Footfall::Middleware counts them, Footfall::Report prints them.
8
+ module Footfall
9
+ end
metadata ADDED
@@ -0,0 +1,126 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: footfall
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Oleh Khomei
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rack
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '2.2'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '2.2'
26
+ - !ruby/object:Gem::Dependency
27
+ name: sqlite3
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '2.0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: rack-test
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :development
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
68
+ - !ruby/object:Gem::Dependency
69
+ name: rake
70
+ requirement: !ruby/object:Gem::Requirement
71
+ requirements:
72
+ - - ">="
73
+ - !ruby/object:Gem::Version
74
+ version: '0'
75
+ type: :development
76
+ prerelease: false
77
+ version_requirements: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - ">="
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ description: |
83
+ Footfall is what a shop counts at its door: people coming in, not who they
84
+ are. Rack middleware that counts page views, visitors, the sites they came
85
+ from and file downloads into a SQLite file, and a report for the terminal.
86
+ No cookies, no IP addresses, no full referrers: a visitor is a hash salted
87
+ anew every day and the salt forgotten the next, so a day's visitors can be
88
+ counted but not followed from day to day or traced back. Bots are left out.
89
+ executables: []
90
+ extensions: []
91
+ extra_rdoc_files: []
92
+ files:
93
+ - CHANGELOG.md
94
+ - LICENSE
95
+ - README.md
96
+ - lib/footfall.rb
97
+ - lib/footfall/database.rb
98
+ - lib/footfall/middleware.rb
99
+ - lib/footfall/report.rb
100
+ - lib/footfall/version.rb
101
+ homepage: https://github.com/varyform/footfall
102
+ licenses:
103
+ - MIT
104
+ metadata:
105
+ source_code_uri: https://github.com/varyform/footfall
106
+ changelog_uri: https://github.com/varyform/footfall/blob/main/CHANGELOG.md
107
+ rubygems_mfa_required: 'true'
108
+ rdoc_options: []
109
+ require_paths:
110
+ - lib
111
+ required_ruby_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '3.2'
116
+ required_rubygems_version: !ruby/object:Gem::Requirement
117
+ requirements:
118
+ - - ">="
119
+ - !ruby/object:Gem::Version
120
+ version: '0'
121
+ requirements: []
122
+ rubygems_version: 4.0.20
123
+ specification_version: 4
124
+ summary: Cookieless visitor, page view and download counts for a Rack app, in a SQLite
125
+ file.
126
+ test_files: []