automatic 14.12.2 → 26.08
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 +5 -5
- data/README.md +635 -83
- data/VERSION +1 -1
- data/automatic.gemspec +109 -248
- data/bin/automatic +20 -139
- data/config/feed2console.yml +10 -4
- data/config/feed2markdown.yml +41 -0
- data/doc/AI_TUTORIAL.md +518 -0
- data/doc/BASIC_DESIGN.md +516 -0
- data/doc/COPYING.LESSER +165 -0
- data/doc/DEPLOYMENT.md +824 -0
- data/doc/LICENSE.md +14 -0
- data/doc/PLUGINS.md +1875 -0
- data/doc/PLUGIN_DEVELOPMENT.md +86 -0
- data/doc/POLICY.md +857 -0
- data/doc/QUICKSTART.md +256 -0
- data/doc/RELEASING.md +381 -0
- data/doc/REQUIREMENTS.md +526 -0
- data/doc/VERSIONS +208 -0
- data/lib/automatic/cli.rb +248 -0
- data/lib/automatic/environment.rb +31 -5
- data/lib/automatic/feed_maker.rb +10 -9
- data/lib/automatic/feed_parser.rb +51 -35
- data/lib/automatic/http.rb +107 -0
- data/lib/automatic/log.rb +49 -18
- data/lib/automatic/opml.rb +3 -1
- data/lib/automatic/pipeline.rb +63 -32
- data/lib/automatic/recipe.rb +56 -17
- data/lib/automatic/version.rb +14 -1
- data/lib/automatic.rb +78 -20
- data/plugins/custom_feed/svn_log.rb +73 -32
- data/plugins/custom_feed/web.rb +348 -0
- data/plugins/filter/absolute_uri.rb +43 -27
- data/plugins/filter/accept.rb +38 -45
- data/plugins/filter/claude.rb +217 -0
- data/plugins/filter/clear.rb +12 -8
- data/plugins/filter/description_link.rb +49 -51
- data/plugins/filter/full_feed.rb +158 -52
- data/plugins/filter/gemini.rb +216 -0
- data/plugins/filter/github_feed.rb +38 -26
- data/plugins/filter/ignore.rb +33 -44
- data/plugins/filter/image.rb +36 -25
- data/plugins/filter/image_source.rb +58 -52
- data/plugins/filter/join.rb +107 -0
- data/plugins/filter/one.rb +19 -26
- data/plugins/filter/open_ai.rb +198 -0
- data/plugins/filter/rand.rb +16 -17
- data/plugins/filter/sakura_ai.rb +205 -0
- data/plugins/filter/sanitize.rb +29 -34
- data/plugins/filter/sort.rb +20 -27
- data/plugins/filter/tumblr_resize.rb +31 -23
- data/plugins/notify/ikachan.rb +86 -48
- data/plugins/provide/fluentd.rb +43 -24
- data/plugins/publish/amazon_s3.rb +73 -40
- data/plugins/publish/console.rb +19 -16
- data/plugins/publish/console_link.rb +20 -17
- data/plugins/publish/eject.rb +48 -26
- data/plugins/publish/fluentd.rb +50 -30
- data/plugins/publish/hatena_bookmark.rb +88 -71
- data/plugins/publish/instapaper.rb +69 -59
- data/plugins/publish/markdown.rb +278 -0
- data/plugins/publish/memcached.rb +35 -29
- data/plugins/store/database.rb +50 -48
- data/plugins/store/digest.rb +212 -0
- data/plugins/store/file.rb +99 -68
- data/plugins/store/full_text.rb +32 -25
- data/plugins/store/permalink.rb +18 -22
- data/plugins/subscription/feed.rb +34 -21
- data/plugins/subscription/link.rb +31 -32
- data/plugins/subscription/text.rb +32 -46
- data/plugins/subscription/tumblr.rb +55 -44
- data/plugins/subscription/xml.rb +40 -36
- metadata +108 -321
- data/Gemfile +0 -38
- data/Rakefile +0 -59
- data/doc/ChangeLog +0 -303
- data/doc/PLUGINS +0 -750
- data/doc/PLUGINS.ja +0 -753
- data/doc/README +0 -511
- data/doc/README.ja +0 -519
- data/plugins/filter/google_news.rb +0 -50
- data/plugins/publish/google_calendar.rb +0 -84
- data/plugins/publish/hipchat.rb +0 -46
- data/plugins/publish/pocket.rb +0 -45
- data/plugins/publish/twitter.rb +0 -58
- data/plugins/subscription/chan_toru.rb +0 -57
- data/plugins/subscription/g_guide.rb +0 -57
- data/plugins/subscription/pocket.rb +0 -51
- data/plugins/subscription/twitter.rb +0 -69
- data/plugins/subscription/twitter_search.rb +0 -50
- data/plugins/subscription/weather.rb +0 -33
- data/script/build +0 -84
- data/spec/fixtures/sampleFeeds.tsv +0 -1
- data/spec/fixtures/sampleFeeds2.tsv +0 -2
- data/spec/fixtures/sampleRecipe.yml +0 -24
- data/spec/lib/automatic/log_spec.rb +0 -32
- data/spec/lib/automatic/pipeline_spec.rb +0 -68
- data/spec/lib/automatic/recipe_spec.rb +0 -40
- data/spec/lib/automatic_spec.rb +0 -99
- data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
- data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
- data/spec/plugins/filter/accept_spec.rb +0 -331
- data/spec/plugins/filter/clear_spec.rb +0 -49
- data/spec/plugins/filter/description_link_spec.rb +0 -138
- data/spec/plugins/filter/full_feed_spec.rb +0 -129
- data/spec/plugins/filter/github_feed_spec.rb +0 -55
- data/spec/plugins/filter/google_news_spec.rb +0 -69
- data/spec/plugins/filter/ignore_spec.rb +0 -328
- data/spec/plugins/filter/image_source_spec.rb +0 -89
- data/spec/plugins/filter/image_spec.rb +0 -65
- data/spec/plugins/filter/one_spec.rb +0 -71
- data/spec/plugins/filter/rand_spec.rb +0 -52
- data/spec/plugins/filter/sanitize_spec.rb +0 -153
- data/spec/plugins/filter/sort_spec.rb +0 -189
- data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
- data/spec/plugins/notify/ikachan_spec.rb +0 -58
- data/spec/plugins/provide/fluentd_spec.rb +0 -49
- data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
- data/spec/plugins/publish/console_spec.rb +0 -30
- data/spec/plugins/publish/eject_spec.rb +0 -40
- data/spec/plugins/publish/fluentd_spec.rb +0 -40
- data/spec/plugins/publish/google_calendar_spec.rb +0 -83
- data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
- data/spec/plugins/publish/hipchat_spec.rb +0 -69
- data/spec/plugins/publish/instapaper_spec.rb +0 -82
- data/spec/plugins/publish/memcached_spec.rb +0 -63
- data/spec/plugins/publish/pocket_spec.rb +0 -51
- data/spec/plugins/publish/twitter_spec.rb +0 -73
- data/spec/plugins/store/file_spec.rb +0 -58
- data/spec/plugins/store/full_text_spec.rb +0 -152
- data/spec/plugins/store/permalink_spec.rb +0 -206
- data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
- data/spec/plugins/subscription/feed_spec.rb +0 -71
- data/spec/plugins/subscription/g_guide_spec.rb +0 -82
- data/spec/plugins/subscription/link_spec.rb +0 -72
- data/spec/plugins/subscription/pocket_spec.rb +0 -57
- data/spec/plugins/subscription/text_spec.rb +0 -84
- data/spec/plugins/subscription/tumblr_spec.rb +0 -74
- data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
- data/spec/plugins/subscription/twitter_spec.rb +0 -73
- data/spec/plugins/subscription/weather_spec.rb +0 -44
- data/spec/plugins/subscription/xml_spec.rb +0 -84
- data/spec/spec_helper.rb +0 -106
- data/spec/user_dir/plugins/store/mock.rb +0 -16
- data/test/fixtures/sampleOPML.xml +0 -11
- data/test/integration/test_absoluteurl.yml +0 -25
- data/test/integration/test_activerecord.yml +0 -24
- data/test/integration/test_add_pocket.yml +0 -26
- data/test/integration/test_chan_toru.yml +0 -21
- data/test/integration/test_descriptionlink.yml +0 -21
- data/test/integration/test_fluentd.yml +0 -22
- data/test/integration/test_fulltext.yml +0 -30
- data/test/integration/test_google_news.yml +0 -21
- data/test/integration/test_googlealert.yml +0 -21
- data/test/integration/test_hatenabookmark.yml +0 -30
- data/test/integration/test_ignore.yml +0 -25
- data/test/integration/test_ignore2.yml +0 -22
- data/test/integration/test_image2local.yml +0 -33
- data/test/integration/test_instapaper.yml +0 -26
- data/test/integration/test_link2local.yml +0 -34
- data/test/integration/test_one.yml +0 -23
- data/test/integration/test_pocket.yml +0 -22
- data/test/integration/test_rand.yml +0 -21
- data/test/integration/test_sanitize.yml +0 -23
- data/test/integration/test_sort.yml +0 -36
- data/test/integration/test_svnlog.yml +0 -15
- data/test/integration/test_text2feed.yml +0 -36
- data/test/integration/test_tumblr2local.yml +0 -43
- data/test/integration/test_twitter_search.yml +0 -22
- data/test/integration/test_weather.yml +0 -19
- data/test/integration/test_xml2fluentd.yml +0 -21
- data/vendor/.gitkeep +0 -0
data/doc/REQUIREMENTS.md
ADDED
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
# Requirements: a plugin pipeline for unattended processing
|
|
2
|
+
|
|
3
|
+
## 1. Purpose of this document
|
|
4
|
+
|
|
5
|
+
This document states what Automatic Ruby is for, what it accepts, what it
|
|
6
|
+
produces, and where its responsibility ends. It does not describe how any of it
|
|
7
|
+
is built; that belongs to [`BASIC_DESIGN.md`](BASIC_DESIGN.md), and how a change
|
|
8
|
+
to it is carried out belongs to [`POLICY.md`](POLICY.md). The two public
|
|
9
|
+
interfaces it names — the Recipe and the plugin contract — are specified in
|
|
10
|
+
[`PLUGINS.md`](PLUGINS.md).
|
|
11
|
+
|
|
12
|
+
It stands on its own. Nothing in it is completed by a document kept in another
|
|
13
|
+
repository.
|
|
14
|
+
|
|
15
|
+
Where this document says the software *does* something, that is a statement
|
|
16
|
+
about the present implementation, reconstructed by reading it. Where it says the
|
|
17
|
+
software *shall* do something, that is a requirement a change is held to.
|
|
18
|
+
|
|
19
|
+
## 2. Name
|
|
20
|
+
|
|
21
|
+
`automaticruby`, published as the RubyGem `automatic` and invoked as the command
|
|
22
|
+
`automatic`. The repository name carries the language because the framework is
|
|
23
|
+
the product: a Ruby program whose behaviour is written in Ruby plugins and
|
|
24
|
+
assembled in a configuration file.
|
|
25
|
+
|
|
26
|
+
## 3. Purpose
|
|
27
|
+
|
|
28
|
+
A person has a recurring job that consists of acquiring information, processing
|
|
29
|
+
what is useful, optionally persisting a record so it is not handled twice, and
|
|
30
|
+
publishing a portable result. Tomorrow they have another job
|
|
31
|
+
of the same shape with different endpoints. Writing each as a script means
|
|
32
|
+
writing the fetching, the filtering, the de-duplication and the retrying again
|
|
33
|
+
every time.
|
|
34
|
+
|
|
35
|
+
Automatic Ruby exists so that those jobs are *assembled* rather than written. A
|
|
36
|
+
step is a plugin — a small Ruby class with one method. A job is a Recipe — a
|
|
37
|
+
YAML file naming the plugins in order and their settings. Running the job is one
|
|
38
|
+
command, which is safe to put in `cron`.
|
|
39
|
+
|
|
40
|
+
The framework's contribution is exactly three things:
|
|
41
|
+
|
|
42
|
+
- a uniform contract every step obeys, so that steps compose,
|
|
43
|
+
- a value passed from step to step, so that they have something to compose over,
|
|
44
|
+
- a loader that finds a step by name, so that a Recipe can name it.
|
|
45
|
+
|
|
46
|
+
Everything else — what is fetched, what is filtered, what is published — belongs
|
|
47
|
+
to a plugin, and plugins are expected to come and go.
|
|
48
|
+
|
|
49
|
+
## 4. What it is not
|
|
50
|
+
|
|
51
|
+
- **Not an application.** It has no behaviour of its own. With no Recipe it does
|
|
52
|
+
nothing, and every useful thing it does is a plugin's doing.
|
|
53
|
+
- **Not a daemon or a scheduler.** One invocation runs one Recipe once and
|
|
54
|
+
exits. Repetition is `cron`'s job, and periodic running is deliberately left
|
|
55
|
+
outside; see section 15.
|
|
56
|
+
- **Not a web application.** It serves nothing and listens on nothing.
|
|
57
|
+
- **Not a feed reader.** Feeds are the shape the pipeline value happens to take
|
|
58
|
+
(section 8), not the purpose. A Recipe that never touches a feed is a normal
|
|
59
|
+
use of it.
|
|
60
|
+
- **Not a general workflow engine.** There are no branches, no loops, no
|
|
61
|
+
conditions and no fan-out. A Recipe is a straight line, and it stays one.
|
|
62
|
+
- **Not a hosted or multi-tenant system.** It runs as one person, on one
|
|
63
|
+
machine, against that person's own accounts and files.
|
|
64
|
+
- **Not a sandbox.** A Recipe names Ruby classes and those classes run with the
|
|
65
|
+
full privileges of the invoking user. See section 17.
|
|
66
|
+
|
|
67
|
+
## 5. Who uses it
|
|
68
|
+
|
|
69
|
+
One person, on their own machine or their own server, running their own Recipes
|
|
70
|
+
against their own accounts. They are able to read and write Ruby, because
|
|
71
|
+
writing a plugin is the intended way to extend the system, and the plugin
|
|
72
|
+
contract is small enough that this is a reasonable expectation rather than a
|
|
73
|
+
burden.
|
|
74
|
+
|
|
75
|
+
There is no notion of a second user, no account, no permission and no
|
|
76
|
+
separation. Two people sharing a Recipe share the credentials in it.
|
|
77
|
+
|
|
78
|
+
## 6. Where it runs
|
|
79
|
+
|
|
80
|
+
- A Unix-like system: GNU/Linux and macOS are what it is used on.
|
|
81
|
+
- A maintained Ruby (section 20), installed either from RubyGems or from a
|
|
82
|
+
source checkout.
|
|
83
|
+
- Unattended, from `cron`, as often as the operator chooses.
|
|
84
|
+
|
|
85
|
+
Windows is not supported. Nothing is known to be deliberately incompatible with
|
|
86
|
+
it, but no plugin, no path handling and no test is written with it in mind, and
|
|
87
|
+
several plugins shell out to Unix commands.
|
|
88
|
+
|
|
89
|
+
## 7. The two public interfaces
|
|
90
|
+
|
|
91
|
+
Two things in this repository are interfaces that people outside it depend on,
|
|
92
|
+
and they are treated accordingly.
|
|
93
|
+
|
|
94
|
+
### 7.1 The Recipe
|
|
95
|
+
|
|
96
|
+
A Recipe is a YAML file listing the plugins of one job, in order, each with its
|
|
97
|
+
settings. Its structure is specified in [`PLUGINS.md`](PLUGINS.md) section 2.
|
|
98
|
+
|
|
99
|
+
A Recipe written for an earlier release shall keep working. Recipes live outside
|
|
100
|
+
this repository, in `~/.automatic/config` and in operators' `cron` entries, and
|
|
101
|
+
this repository cannot see them, cannot migrate them and shall not silently
|
|
102
|
+
change what they mean. Removing a key, renaming a key, changing a default so
|
|
103
|
+
that an unchanged Recipe does something else, and changing how a value is
|
|
104
|
+
interpreted are all breaking changes, are made only deliberately, and are
|
|
105
|
+
recorded in [`VERSIONS`](VERSIONS).
|
|
106
|
+
|
|
107
|
+
Adding an optional key with a default that preserves current behaviour is not a
|
|
108
|
+
breaking change.
|
|
109
|
+
|
|
110
|
+
### 7.2 The plugin contract
|
|
111
|
+
|
|
112
|
+
The contract a plugin class obeys — where it lives, how it is named, how it is
|
|
113
|
+
constructed, what it receives and what it must return — is specified in
|
|
114
|
+
[`PLUGINS.md`](PLUGINS.md) section 3.
|
|
115
|
+
|
|
116
|
+
It is an interface because plugins exist outside this repository, under
|
|
117
|
+
`~/.automatic/plugins`, and the framework loads them by the same rules it loads
|
|
118
|
+
its own. Changing the contract breaks code this repository has never seen.
|
|
119
|
+
|
|
120
|
+
## 8. The pipeline value
|
|
121
|
+
|
|
122
|
+
Every plugin receives one value and returns one value. That value is the
|
|
123
|
+
pipeline, and it is:
|
|
124
|
+
|
|
125
|
+
> an `Array` of feed objects, where a feed object responds to `#items`, and each
|
|
126
|
+
> item responds to `#title`, `#link`, `#description`, `#date`, `#author`,
|
|
127
|
+
> `#comments`, `#source`, `#enclosure` and `#content_encoded`.
|
|
128
|
+
|
|
129
|
+
In practice the elements are RSS objects produced by Ruby's `RSS::Maker` or by
|
|
130
|
+
`RSS::Parser`. `Automatic::FeedMaker` builds them from plain values, so a plugin
|
|
131
|
+
that acquires something which is not a feed — a row of a TSV file, an API
|
|
132
|
+
response, a weather report — converts it into this shape and the rest of the
|
|
133
|
+
pipeline is unaffected.
|
|
134
|
+
|
|
135
|
+
This is the framework's one substantive constraint on plugins, and it is what
|
|
136
|
+
makes them compose. Three consequences are requirements:
|
|
137
|
+
|
|
138
|
+
- A plugin shall accept this shape and return this shape. A plugin that returns
|
|
139
|
+
something else ends the pipeline for every plugin after it.
|
|
140
|
+
- `link` may be `nil`. Several filters signal "not applicable" by setting it,
|
|
141
|
+
and a plugin that dereferences it without checking is at fault.
|
|
142
|
+
- The names above are RSS names, and they are used for values that are not RSS.
|
|
143
|
+
`title` may hold a weather condition and `description` may hold an arbitrary
|
|
144
|
+
string. This is a known cost of having one shape, and it is accepted.
|
|
145
|
+
|
|
146
|
+
The value has no schema beyond that, no validation and no version. A plugin that
|
|
147
|
+
needs a field the previous plugin never set gets `nil`.
|
|
148
|
+
|
|
149
|
+
This is an **internal** representation, and it is not a statement about what a
|
|
150
|
+
run produces. A plugin that writes the pipeline out in some other form — a
|
|
151
|
+
Markdown document, a row in a database, a request body — serializes it at the
|
|
152
|
+
moment it leaves the pipeline, and the value the next plugin receives is
|
|
153
|
+
unchanged. An output format is therefore never a second pipeline
|
|
154
|
+
representation, and shall not become one; see section 10.2.
|
|
155
|
+
|
|
156
|
+
## 9. Input
|
|
157
|
+
|
|
158
|
+
The framework itself reads:
|
|
159
|
+
|
|
160
|
+
- **the Recipe**, named by `-c` (section 11),
|
|
161
|
+
- **the user directory**, `~/.automatic` (section 13),
|
|
162
|
+
- **plugin files**, from the user directory and from the installed package.
|
|
163
|
+
|
|
164
|
+
Everything else is read by a plugin, on its own account. Feeds, web pages, APIs,
|
|
165
|
+
TSV files, XML documents, a Subversion repository and a SQLite database are all
|
|
166
|
+
plugin inputs, not framework inputs.
|
|
167
|
+
|
|
168
|
+
The framework passes no ambient input to plugins. A plugin's entire input is the
|
|
169
|
+
`config` it is given and the pipeline it receives.
|
|
170
|
+
|
|
171
|
+
## 10. Output
|
|
172
|
+
|
|
173
|
+
### 10.1 What the framework writes
|
|
174
|
+
|
|
175
|
+
The framework itself writes:
|
|
176
|
+
|
|
177
|
+
- **a log**, to standard output (section 14),
|
|
178
|
+
- **the user directory**, when `scaffold` is asked for.
|
|
179
|
+
|
|
180
|
+
Everything a run actually accomplishes is written by a plugin: a file, a row in
|
|
181
|
+
SQLite, a request to a remote service, a line on the terminal. The framework
|
|
182
|
+
does not know what any of it is and does not verify it.
|
|
183
|
+
|
|
184
|
+
The final pipeline value is discarded. A Recipe whose last plugin only
|
|
185
|
+
transforms the pipeline has done nothing observable, and that is not an error.
|
|
186
|
+
|
|
187
|
+
### 10.2 What a run publishes
|
|
188
|
+
|
|
189
|
+
Where the collected information ends up is decided by the Recipe, and the
|
|
190
|
+
plugins that decide it are the `Publish` category. Two kinds of destination have
|
|
191
|
+
always existed side by side: a remote service, reached with a credential over
|
|
192
|
+
the network, and something local — the terminal, a file, a database. Neither is
|
|
193
|
+
the system's purpose. Automatic Ruby is not a means of feeding one particular
|
|
194
|
+
reader, one particular service or one particular protocol, and a requirement
|
|
195
|
+
written as though it were would be wrong about what the system is for.
|
|
196
|
+
|
|
197
|
+
Leaving the collected information behind as a **document that outlives the run**
|
|
198
|
+
is as much a use of the system as sending it somewhere. That document is read by
|
|
199
|
+
a person, kept in version control, searched with ordinary Unix tools, and given
|
|
200
|
+
to a program — including a large language model or an agent — as input to
|
|
201
|
+
summarize, classify or reorganize. These are the same document and the same
|
|
202
|
+
requirement, not two.
|
|
203
|
+
|
|
204
|
+
**Markdown is the standard publication format** for that purpose, in the sense
|
|
205
|
+
that it is what a Recipe publishes when it has no reason to publish to a
|
|
206
|
+
particular service. Its properties are the reason, and they are technical ones:
|
|
207
|
+
|
|
208
|
+
- a person reads it as it is, with no tool and no rendering step;
|
|
209
|
+
- it diffs and merges, so a growing document belongs in Git;
|
|
210
|
+
- `grep`, `sed` and the rest operate on it without a parser;
|
|
211
|
+
- a program consumes it as text, which is the form a language model or an agent
|
|
212
|
+
takes its input in;
|
|
213
|
+
- it needs no service, no account, no credential and no network;
|
|
214
|
+
- it is plain text, so it stays readable for as long as the filesystem does.
|
|
215
|
+
|
|
216
|
+
The requirements that follow:
|
|
217
|
+
|
|
218
|
+
- **A publication format shall be available that needs nothing outside the
|
|
219
|
+
machine.** A Recipe that collects, filters and de-duplicates shall be able to
|
|
220
|
+
finish by writing what it has, without an account anywhere.
|
|
221
|
+
- **Markdown output is an ordinary Publish plugin**, subject to the plugin
|
|
222
|
+
contract of section 7.2 and to nothing else. It is not a framework feature,
|
|
223
|
+
the framework gains no knowledge of Markdown, and section 23 applies to it as
|
|
224
|
+
to anything else.
|
|
225
|
+
- **Nothing is published implicitly.** A Recipe publishes what it names and
|
|
226
|
+
nothing more. The framework shall not append a publishing step to a Recipe
|
|
227
|
+
that does not ask for one, and a Recipe written before Markdown output existed
|
|
228
|
+
shall behave exactly as it did.
|
|
229
|
+
- **The output format is not the pipeline value.** Serializing to Markdown
|
|
230
|
+
happens at the boundary, in the publishing plugin, and leaves section 8's
|
|
231
|
+
value untouched. No second representation is introduced, and the plugins
|
|
232
|
+
before and after are unaffected.
|
|
233
|
+
- **RSS and Atom remain input formats.** `SubscriptionFeed` and the rest are
|
|
234
|
+
unaffected by any of this: a feed is still one of the ordinary ways to acquire
|
|
235
|
+
something, and none of it is deprecated. That the pipeline value has the shape
|
|
236
|
+
of a feed is an internal matter (section 8); it neither obliges a run to
|
|
237
|
+
publish a feed nor makes feeds less useful to read.
|
|
238
|
+
|
|
239
|
+
## 11. The command line
|
|
240
|
+
|
|
241
|
+
One executable, `automatic`, with two modes.
|
|
242
|
+
|
|
243
|
+
**Running a Recipe.** `automatic -c RECIPE` loads the Recipe and runs its
|
|
244
|
+
pipeline. `RECIPE` is either a path, or a bare name resolved inside
|
|
245
|
+
`~/.automatic/config`.
|
|
246
|
+
|
|
247
|
+
**Subcommands.** Auxiliary tools that do not involve a Recipe:
|
|
248
|
+
|
|
249
|
+
| Subcommand | What it is for |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| `scaffold` | Create the user directory (section 13) |
|
|
252
|
+
| `unscaffold` | Remove the user directory |
|
|
253
|
+
| `autodiscovery <url>` | Print the feed URLs advertised by a page |
|
|
254
|
+
| `feedparser <url>` | Parse a feed and print the result |
|
|
255
|
+
| `inspect <url>` | Discover a page's feeds, then parse the first |
|
|
256
|
+
| `opmlparser <path>` | Print the feed URLs in an OPML file |
|
|
257
|
+
| `log <level> <message>` | Emit one line in the framework's log format |
|
|
258
|
+
|
|
259
|
+
The last five exist to answer "will this work as a Recipe input?" before writing
|
|
260
|
+
the Recipe. They are diagnostic and they may print freely.
|
|
261
|
+
|
|
262
|
+
Requirements on the command line:
|
|
263
|
+
|
|
264
|
+
- `--help` and `--version` shall be available, shall print to standard output
|
|
265
|
+
and shall exit `0`.
|
|
266
|
+
- Exit status shall distinguish success from failure: `0` when the requested
|
|
267
|
+
work was done, `1` when it was not, `2` when the command line itself was
|
|
268
|
+
rejected. A run that fails shall not exit `0`.
|
|
269
|
+
- Diagnostics shall go to standard error, so that the output of `feedparser` and
|
|
270
|
+
friends can be redirected without collecting them.
|
|
271
|
+
- The set of subcommands and the meaning of `-c` are part of the compatibility
|
|
272
|
+
promise of section 7.1.
|
|
273
|
+
|
|
274
|
+
## 12. Execution and failure
|
|
275
|
+
|
|
276
|
+
A run is: load the Recipe, then for each plugin entry in order, load the class,
|
|
277
|
+
construct it with its `config` and the current pipeline, call `run`, and take
|
|
278
|
+
the result as the pipeline for the next entry.
|
|
279
|
+
|
|
280
|
+
The requirements on failure:
|
|
281
|
+
|
|
282
|
+
- **A pipeline is not partially rerunnable.** There is no checkpoint and no
|
|
283
|
+
resume. A failed run is rerun from the start, so plugins that must not repeat
|
|
284
|
+
their effect are responsible for saying so; `StorePermalink` exists for this.
|
|
285
|
+
- **A plugin that raises ends the run.** The framework does not catch exceptions
|
|
286
|
+
from plugins. The Recipe is a sequence in which each step consumes the
|
|
287
|
+
previous one's output, so continuing past a failed step would run the
|
|
288
|
+
remaining steps on a value their author never intended.
|
|
289
|
+
- **Retrying belongs to the plugin.** Plugins that reach the network take
|
|
290
|
+
`retry` and `interval` and handle their own transient failures. The framework
|
|
291
|
+
offers no retry, and shall not acquire one that changes what a Recipe means.
|
|
292
|
+
- **A failure shall be visible.** Whatever a plugin decides to do about an
|
|
293
|
+
error, it logs it, and the process exit status reflects whether the run
|
|
294
|
+
completed. Silently returning an empty pipeline is a defect.
|
|
295
|
+
- **A Recipe naming a plugin that does not exist fails immediately**, before any
|
|
296
|
+
plugin runs, with a message naming the plugin.
|
|
297
|
+
|
|
298
|
+
## 13. The user directory
|
|
299
|
+
|
|
300
|
+
`~/.automatic` is where an installation's own material lives, so that it
|
|
301
|
+
survives upgrading or reinstalling the gem.
|
|
302
|
+
|
|
303
|
+
| Path | Holds |
|
|
304
|
+
| --- | --- |
|
|
305
|
+
| `~/.automatic/config` | Recipes. A bare `-c` name is resolved here. |
|
|
306
|
+
| `~/.automatic/plugins` | The operator's own plugins, in category subdirectories. |
|
|
307
|
+
| `~/.automatic/db` | SQLite databases written by the store plugins. |
|
|
308
|
+
| `~/.automatic/assets` | Data files plugins need, such as the fulltext siteinfo. |
|
|
309
|
+
|
|
310
|
+
Requirements:
|
|
311
|
+
|
|
312
|
+
- The user directory takes precedence over the installed package. A plugin under
|
|
313
|
+
`~/.automatic/plugins` shadows a plugin of the same name shipped in the gem.
|
|
314
|
+
Overriding a shipped plugin without editing the installation is the point.
|
|
315
|
+
- Each part is optional. Where a directory is absent the framework falls back to
|
|
316
|
+
the corresponding directory inside the installation, and a Recipe that needs
|
|
317
|
+
none of them runs without a user directory at all.
|
|
318
|
+
- `scaffold` creates the directory and seeds it: the category subdirectories, the
|
|
319
|
+
example Recipes, and the shipped assets. It shall not overwrite what is
|
|
320
|
+
already there.
|
|
321
|
+
- `unscaffold` removes the whole directory, including Recipes and databases the
|
|
322
|
+
operator put there. It is destructive by design and says so.
|
|
323
|
+
|
|
324
|
+
## 14. Logging
|
|
325
|
+
|
|
326
|
+
One log, to standard output, one line per event, through Ruby's `Logger`.
|
|
327
|
+
|
|
328
|
+
- Four levels, in order: `info`, `warn`, `error`, `none`. `none` silences
|
|
329
|
+
everything.
|
|
330
|
+
- The level is set per Recipe, by `global.log.level`. It is the one framework
|
|
331
|
+
setting a Recipe carries.
|
|
332
|
+
- The framework logs which Recipe was loaded and which database was opened.
|
|
333
|
+
Plugins log what they fetched, what they skipped and what failed.
|
|
334
|
+
- The log is what the operator has after an unattended run. Where a plugin
|
|
335
|
+
decides not to fail, the log is the only record that anything went wrong, so
|
|
336
|
+
a swallowed error is logged at `warn` or `error`, never at `info` and never
|
|
337
|
+
not at all.
|
|
338
|
+
|
|
339
|
+
## 15. Scheduling
|
|
340
|
+
|
|
341
|
+
Out of scope, deliberately. The system provides no scheduler, no daemon, no lock
|
|
342
|
+
file and no "run every N minutes". `automatic -c recipe.yml` in a `cron` entry
|
|
343
|
+
is the intended deployment, and `cron` keeps the responsibility for when.
|
|
344
|
+
|
|
345
|
+
Two consequences the operator owns: overlapping runs are possible if a run
|
|
346
|
+
outlasts its interval, and a Recipe that reaches a rate-limited service is
|
|
347
|
+
scheduled with that in mind.
|
|
348
|
+
|
|
349
|
+
## 16. External services
|
|
350
|
+
|
|
351
|
+
The framework reaches nothing. Plugins reach the network, and where they do:
|
|
352
|
+
|
|
353
|
+
- The endpoint, the protocol, the authentication and the failure handling belong
|
|
354
|
+
entirely to that plugin.
|
|
355
|
+
- Credentials are given to the plugin in its Recipe `config` (section 17).
|
|
356
|
+
- TLS certificates shall be verified. A plugin shall not disable verification.
|
|
357
|
+
- Nothing is fetched on the framework's own initiative: no update check, no
|
|
358
|
+
telemetry, no phone-home.
|
|
359
|
+
|
|
360
|
+
Services shut down and APIs are replaced, and whether a given plugin still works
|
|
361
|
+
is a fact about the outside world rather than about this repository. It is
|
|
362
|
+
recorded per plugin in [`PLUGINS.md`](PLUGINS.md) section 6 and kept current
|
|
363
|
+
there. Three requirements follow:
|
|
364
|
+
|
|
365
|
+
- **A shipped plugin shall be practically usable on the supported Ruby
|
|
366
|
+
versions.** Shipping it is a statement that it does what its entry says,
|
|
367
|
+
given what that entry says the operator must provide. A plugin for which that
|
|
368
|
+
statement can no longer be made shall be corrected or removed; it shall not
|
|
369
|
+
be carried indefinitely as a record of what once existed, which is what the
|
|
370
|
+
version control history is for.
|
|
371
|
+
- **A plugin that cannot work shall say so** — in the catalogue and, where it
|
|
372
|
+
runs at all, in its log output. It shall not be quietly left to fail at
|
|
373
|
+
runtime.
|
|
374
|
+
- **A plugin shall never be made to pass a test by simulating a service that no
|
|
375
|
+
longer exists.** Deleting the test, or marking it as requiring a service that
|
|
376
|
+
is gone, is correct; a stub that makes a dead integration look alive is not.
|
|
377
|
+
|
|
378
|
+
## 17. Trust and credentials
|
|
379
|
+
|
|
380
|
+
**A Recipe is trusted local configuration**, equivalent to a shell script the
|
|
381
|
+
operator wrote. This is the trust boundary, and everything below follows from
|
|
382
|
+
it.
|
|
383
|
+
|
|
384
|
+
A Recipe names Ruby classes and the framework loads and runs them. A Recipe also
|
|
385
|
+
names plugin *files*, indirectly, through the loader's search path. Anyone who
|
|
386
|
+
can write a Recipe, or write a file under `~/.automatic/plugins`, can execute
|
|
387
|
+
arbitrary code as the operator. Therefore:
|
|
388
|
+
|
|
389
|
+
- Recipes and plugin directories are the operator's own, protected by file
|
|
390
|
+
permissions. There is no supported use in which they come from elsewhere.
|
|
391
|
+
- A Recipe from an untrusted source is not to be run, and the system offers no
|
|
392
|
+
mode in which doing so would be safe. This is not a limitation to be lifted by
|
|
393
|
+
hardening the YAML parser.
|
|
394
|
+
- Even so, the Recipe parser shall not be a second, avoidable path to code
|
|
395
|
+
execution: YAML shall be loaded so that the document cannot name arbitrary
|
|
396
|
+
Ruby classes to instantiate. Refusing that costs nothing, since no Recipe
|
|
397
|
+
needs it.
|
|
398
|
+
|
|
399
|
+
Credentials — API tokens, passwords, keys — are values in the Recipe's `config`,
|
|
400
|
+
which means a Recipe holding them is a secret file, and the operator restricts
|
|
401
|
+
its permissions. This is a weakness inherited from the original design, and it
|
|
402
|
+
is recorded rather than glossed:
|
|
403
|
+
|
|
404
|
+
- No credential shall be committed to this repository, in a Recipe, an example,
|
|
405
|
+
a fixture or a test.
|
|
406
|
+
- No credential shall be written to the log. A plugin that logs its own settings
|
|
407
|
+
is a defect.
|
|
408
|
+
- The example Recipes shipped in `config/` shall need no credential.
|
|
409
|
+
|
|
410
|
+
## 18. Persistence
|
|
411
|
+
|
|
412
|
+
Two kinds, both a plugin's business rather than the framework's.
|
|
413
|
+
|
|
414
|
+
**SQLite, through ActiveRecord.** The store plugins keep a table of what has
|
|
415
|
+
been seen so that the next run skips it: `StorePermalink` on the link,
|
|
416
|
+
`StoreFullText` on the link and title with the article body. The database file
|
|
417
|
+
is named in the Recipe and lives in `~/.automatic/db`, or in the installation's
|
|
418
|
+
`db/` when there is no user directory. The table is created on first use from
|
|
419
|
+
the plugin's own column definition; there are no migrations and no schema
|
|
420
|
+
version.
|
|
421
|
+
|
|
422
|
+
Requirements: ActiveRecord is used as a library and this is not a Rails
|
|
423
|
+
application; nothing shall introduce one. Equally, ActiveRecord shall not be
|
|
424
|
+
replaced by a hand-written database layer merely because it is a large
|
|
425
|
+
dependency — it is what the existing databases were written by, and operators
|
|
426
|
+
have those files. It is the store plugins' dependency and not the framework's:
|
|
427
|
+
`activerecord` and `sqlite3` shall be installed by the operator who uses those
|
|
428
|
+
plugins, and a Recipe that stores nothing shall run without either.
|
|
429
|
+
|
|
430
|
+
**The filesystem.** `StoreFile` downloads what the pipeline points at and
|
|
431
|
+
rewrites the item's link to a `file://` URI, which is how a later publishing
|
|
432
|
+
plugin knows to upload a local file rather than a remote one.
|
|
433
|
+
|
|
434
|
+
## 19. Packaging and distribution
|
|
435
|
+
|
|
436
|
+
- Distributed as the RubyGem `automatic`, installable with `gem install
|
|
437
|
+
automatic`, providing the `automatic` executable.
|
|
438
|
+
- Also usable from a source checkout, through `bundle exec bin/automatic`.
|
|
439
|
+
- Dependencies are declared, versioned and resolved by Bundler and RubyGems.
|
|
440
|
+
Nothing is vendored into this repository.
|
|
441
|
+
- **The core install shall be small.** A dependency needed by one plugin shall
|
|
442
|
+
not be required to install the framework or to run a Recipe that does not use
|
|
443
|
+
that plugin; see [`POLICY.md`](POLICY.md) section 9. Requiring an operator who
|
|
444
|
+
publishes to a console to install an AWS SDK is a defect.
|
|
445
|
+
- Gem sources shall be HTTPS and shall be currently operating.
|
|
446
|
+
|
|
447
|
+
## 20. Supported Ruby
|
|
448
|
+
|
|
449
|
+
Two statements are made here, and they are deliberately different.
|
|
450
|
+
|
|
451
|
+
**The supported range** is **Ruby 3.3 through Ruby 4.0**.
|
|
452
|
+
|
|
453
|
+
- The floor is **Ruby 3.3**: the oldest maintained release the dependency set is
|
|
454
|
+
resolved and tested against. Nothing older is tested or supported.
|
|
455
|
+
- The code shall be written against APIs the whole range shares. Where a Ruby
|
|
456
|
+
release deprecates or removes one, the replacement that works on the whole
|
|
457
|
+
range is used, rather than a `RUBY_VERSION` branch; see
|
|
458
|
+
[`POLICY.md`](POLICY.md) section 2.4.
|
|
459
|
+
- The gemspec's `required_ruby_version` is a lower bound and not an upper one,
|
|
460
|
+
so a Ruby newer than the range is permitted rather than refused. Refusing one
|
|
461
|
+
would need a new release of this gem to lift.
|
|
462
|
+
- The floor shall not be lowered to accommodate an unmaintained Ruby, and shall
|
|
463
|
+
not be raised to the newest release for its own sake. It moves when a
|
|
464
|
+
dependency the project needs moves it, or when the version drops out of the
|
|
465
|
+
distributions the project is used on.
|
|
466
|
+
|
|
467
|
+
**The continuously validated versions** are **3.3, 3.4 and 4.0** — the ends of
|
|
468
|
+
the range and the release in the middle.
|
|
469
|
+
|
|
470
|
+
- CI runs representative versions rather than every intermediate release. The
|
|
471
|
+
cost of a matrix entry is paid on every commit, and a third entry between two
|
|
472
|
+
that pass says little about a range whose code shares one set of APIs.
|
|
473
|
+
- **A version's absence from the matrix is not a statement that it fails.** It
|
|
474
|
+
is a statement that it is not verified on every commit. Nothing is written to
|
|
475
|
+
be deliberately incompatible with a supported Ruby that the matrix omits.
|
|
476
|
+
- Adding a released Ruby to the matrix is how support for it becomes continuous,
|
|
477
|
+
and is a small change.
|
|
478
|
+
|
|
479
|
+
One statement of the supported range lives in the gemspec, one statement of the
|
|
480
|
+
validated set lives in the CI matrix, and the README and the documents agree
|
|
481
|
+
with both.
|
|
482
|
+
|
|
483
|
+
## 21. Portability
|
|
484
|
+
|
|
485
|
+
- No compiled extension of this repository's own. Extensions come only from
|
|
486
|
+
dependencies.
|
|
487
|
+
- No absolute path outside `~/.automatic` and the installation directory.
|
|
488
|
+
- Nothing tied to a particular distribution, filesystem layout or init system.
|
|
489
|
+
- Shelling out is a plugin's business, and a plugin that does it is expected to
|
|
490
|
+
fail cleanly where the command is absent.
|
|
491
|
+
|
|
492
|
+
## 22. Testability
|
|
493
|
+
|
|
494
|
+
- The framework's own units — the Recipe loader, the plugin loader, the
|
|
495
|
+
pipeline, the log — shall be testable without a network, without credentials
|
|
496
|
+
and without a user directory.
|
|
497
|
+
- **The default test suite shall reach no network and require no credential.**
|
|
498
|
+
A test that needs either is not part of it.
|
|
499
|
+
- A plugin's test constructs the plugin with a `config` and a pipeline, calls
|
|
500
|
+
`run`, and asserts on the returned pipeline. The framework provides the means
|
|
501
|
+
to build a pipeline for this.
|
|
502
|
+
- Where a plugin's dependency cannot be installed or its service no longer
|
|
503
|
+
exists, its test is excluded from the default suite by that fact and not by a
|
|
504
|
+
simulation of the service. See section 16.
|
|
505
|
+
- The integration Recipes under `test/integration` are run by hand against real
|
|
506
|
+
services, and are not part of the default suite or of CI.
|
|
507
|
+
|
|
508
|
+
## 23. Simplicity
|
|
509
|
+
|
|
510
|
+
The framework is under seven hundred lines of Ruby and is meant to stay that
|
|
511
|
+
size. It is the small fixed part that plugins are written against, and it earns
|
|
512
|
+
its keep by not changing.
|
|
513
|
+
|
|
514
|
+
- A capability that can live in a plugin lives in a plugin.
|
|
515
|
+
- A framework feature that only one plugin would use does not belong to the
|
|
516
|
+
framework.
|
|
517
|
+
- Long-standing behaviour is left alone unless there is a reason beyond taste. A
|
|
518
|
+
pipeline that has been in `cron` for a decade has earned the benefit of the
|
|
519
|
+
doubt.
|
|
520
|
+
|
|
521
|
+
## 24. Licence
|
|
522
|
+
|
|
523
|
+
Automatic Ruby is dual-licensed under the GNU General Public License, version 3,
|
|
524
|
+
or the GNU Lesser General Public License, version 3. A user may choose either
|
|
525
|
+
license at their discretion. See [`LICENSE.md`](LICENSE.md),
|
|
526
|
+
[`COPYING`](COPYING) and [`COPYING.LESSER`](COPYING.LESSER).
|