factorix 0.5.1 → 0.7.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.
Files changed (70) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +1 -1
  4. data/exe/factorix +17 -0
  5. data/lib/factorix/api/mod_download_api.rb +11 -6
  6. data/lib/factorix/api/mod_info.rb +2 -2
  7. data/lib/factorix/api/mod_management_api.rb +1 -1
  8. data/lib/factorix/api/mod_portal_api.rb +6 -49
  9. data/lib/factorix/api_credential.rb +1 -1
  10. data/lib/factorix/cache/base.rb +116 -0
  11. data/lib/factorix/cache/entry.rb +25 -0
  12. data/lib/factorix/cache/file_system.rb +137 -57
  13. data/lib/factorix/cache/redis.rb +287 -0
  14. data/lib/factorix/cache/s3.rb +388 -0
  15. data/lib/factorix/cli/commands/backup_support.rb +1 -1
  16. data/lib/factorix/cli/commands/base.rb +3 -3
  17. data/lib/factorix/cli/commands/cache/evict.rb +19 -24
  18. data/lib/factorix/cli/commands/cache/stat.rb +66 -67
  19. data/lib/factorix/cli/commands/command_wrapper.rb +5 -5
  20. data/lib/factorix/cli/commands/completion.rb +1 -2
  21. data/lib/factorix/cli/commands/confirmable.rb +1 -1
  22. data/lib/factorix/cli/commands/download_support.rb +2 -7
  23. data/lib/factorix/cli/commands/mod/check.rb +1 -1
  24. data/lib/factorix/cli/commands/mod/disable.rb +1 -1
  25. data/lib/factorix/cli/commands/mod/download.rb +7 -7
  26. data/lib/factorix/cli/commands/mod/edit.rb +10 -13
  27. data/lib/factorix/cli/commands/mod/enable.rb +1 -1
  28. data/lib/factorix/cli/commands/mod/image/add.rb +3 -6
  29. data/lib/factorix/cli/commands/mod/image/edit.rb +2 -5
  30. data/lib/factorix/cli/commands/mod/image/list.rb +5 -8
  31. data/lib/factorix/cli/commands/mod/install.rb +7 -7
  32. data/lib/factorix/cli/commands/mod/list.rb +7 -7
  33. data/lib/factorix/cli/commands/mod/search.rb +13 -12
  34. data/lib/factorix/cli/commands/mod/settings/dump.rb +3 -3
  35. data/lib/factorix/cli/commands/mod/settings/restore.rb +2 -2
  36. data/lib/factorix/cli/commands/mod/show.rb +22 -23
  37. data/lib/factorix/cli/commands/mod/sync.rb +8 -8
  38. data/lib/factorix/cli/commands/mod/uninstall.rb +1 -1
  39. data/lib/factorix/cli/commands/mod/update.rb +11 -43
  40. data/lib/factorix/cli/commands/mod/upload.rb +7 -10
  41. data/lib/factorix/cli/commands/path.rb +2 -2
  42. data/lib/factorix/cli/commands/portal_support.rb +27 -0
  43. data/lib/factorix/cli/commands/version.rb +1 -1
  44. data/lib/factorix/container.rb +155 -0
  45. data/lib/factorix/dependency/parser.rb +1 -1
  46. data/lib/factorix/errors.rb +3 -0
  47. data/lib/factorix/http/cache_decorator.rb +5 -5
  48. data/lib/factorix/http/client.rb +3 -3
  49. data/lib/factorix/info_json.rb +7 -7
  50. data/lib/factorix/mod_list.rb +2 -2
  51. data/lib/factorix/mod_settings.rb +2 -2
  52. data/lib/factorix/portal.rb +3 -2
  53. data/lib/factorix/runtime/user_configurable.rb +9 -9
  54. data/lib/factorix/service_credential.rb +3 -3
  55. data/lib/factorix/transfer/downloader.rb +19 -11
  56. data/lib/factorix/version.rb +1 -1
  57. data/lib/factorix.rb +110 -1
  58. data/sig/factorix/api/mod_download_api.rbs +1 -2
  59. data/sig/factorix/cache/base.rbs +28 -0
  60. data/sig/factorix/cache/entry.rbs +14 -0
  61. data/sig/factorix/cache/file_system.rbs +7 -6
  62. data/sig/factorix/cache/redis.rbs +36 -0
  63. data/sig/factorix/cache/s3.rbs +38 -0
  64. data/sig/factorix/container.rbs +15 -0
  65. data/sig/factorix/errors.rbs +3 -0
  66. data/sig/factorix/portal.rbs +1 -1
  67. data/sig/factorix.rbs +99 -0
  68. metadata +27 -4
  69. data/lib/factorix/application.rb +0 -218
  70. data/sig/factorix/application.rbs +0 -86
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d0313b61d47124abdc416a512ef8027e0885e26aed91202c422349e43d9ec667
4
- data.tar.gz: 00ce104d88ff8a4a5c1e5ea54afbba614775bf93e1c808c95549b458974883f3
3
+ metadata.gz: f1d6b99d769a238f64676cfd19aef4d0f8852d2dc38df210eb753a8bc202f67f
4
+ data.tar.gz: c51b472d2eb47e7d0d39c4789112da30faf5fbf7e225da330d19e989bce86f43
5
5
  SHA512:
6
- metadata.gz: 7dcd0c9873615cb03f613f7298c535ad4bf2ceb9bdd640b4fcfee3b2cfd31860bc62daaa60568a07149c3ae8a806112bcfc369179e18457bf16c0307f5bc89db
7
- data.tar.gz: 29a513f560d14e1b8bc1fa07e1de2378685de195ae97c79b4993f762af33e245da381c6554eee739494425fa59beb2755a15a2734b917473c9f4baf247171500
6
+ metadata.gz: 0e9bf68578d26f4b08e77028b1da59ff8d972f807822be5fa19ca1eb951e89412a3314a3db5fde1fcd821f399ee6c9e4e8c28e37d2d0c5e3d000046c5e663d2f
7
+ data.tar.gz: 81222393a0ab0a6b6e2cbbccac52dec243d4806ef688a1240a01367142094e368175caba564d4842ebd627ebdef98e322f8217b14a3424e8e20c7bbeddc248a4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,50 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.7.0] - 2026-01-24
4
+
5
+ ### Added
6
+
7
+ - Add pluggable cache backend architecture with Redis and S3 support (#18, #19)
8
+ - Configure backend per cache type: `config.cache.<type>.backend = :file_system | :redis | :s3`
9
+ - **Redis backend** (`Cache::Redis`): requires `redis` gem (~> 5)
10
+ - Distributed locking via Lua script for atomic lock release
11
+ - Auto-namespaced keys: `factorix-cache:{cache_type}:{key}`
12
+ - **S3 backend** (`Cache::S3`): requires `aws-sdk-s3` gem
13
+ - Distributed locking via conditional PUT (`if_none_match: "*"`)
14
+ - TTL managed via S3 custom metadata, age from native `Last-Modified`
15
+ - `cache stat` command displays backend-specific information (directory, URL, bucket, etc.)
16
+
17
+ ### Changed
18
+
19
+ - Refactor `Cache::FileSystem` to use `cache_type:` parameter instead of `root:` (#25)
20
+ - Aligns interface with other backends for consistent initialization
21
+ - Cache directory is now auto-computed from `Container[:runtime].factorix_cache_dir / cache_type`
22
+
23
+ ### Removed
24
+
25
+ - Remove deprecated `Factorix::Application` compatibility class
26
+ - Use `Factorix::Container` for DI (`[]`, `resolve`, `register`)
27
+ - Use `Factorix.config` and `Factorix.configure` for configuration
28
+
29
+ ## [0.6.0] - 2026-01-18
30
+
31
+ ### Changed
32
+
33
+ - Reorganize configuration and DI container interfaces (#7, #9)
34
+ - `Factorix::Application` renamed to `Factorix::Container` (DI container only)
35
+ - Configuration interface (`config`, `configure`, `load_config`) moved to `Factorix` module
36
+ - Use `Factorix.configure { |c| ... }` instead of `Factorix::Container.configure { |c| ... }`
37
+
38
+ ### Fixed
39
+
40
+ - Fix integer CLI options being parsed as strings after dry-cli 1.4.0 update (#12)
41
+
42
+ ### Deprecated
43
+
44
+ - `Factorix::Application` still works but emits deprecation warnings; will be removed in v1.0
45
+ - DI methods (`[]`, `resolve`, `register`) delegate to `Factorix::Container`
46
+ - Configuration methods (`config`, `configure`) delegate to `Factorix`
47
+
3
48
  ## [0.5.1] - 2026-01-13
4
49
 
5
50
  ### Fixed
data/README.md CHANGED
@@ -65,7 +65,7 @@ $EDITOR ~/.config/factorix/config.rb
65
65
 
66
66
  **Example configuration:**
67
67
  ```ruby
68
- Factorix::Application.configure do |config|
68
+ Factorix.configure do |config|
69
69
  config.runtime.executable_path = "/Applications/Factorio.app/Contents/MacOS/factorio"
70
70
  config.runtime.user_dir = "#{Dir.home}/Library/Application Support/factorio"
71
71
  config.runtime.data_dir = "/Applications/Factorio.app/Contents/data"
data/exe/factorix CHANGED
@@ -8,6 +8,23 @@ require "zip"
8
8
  # Suppress warnings about invalid dates in ZIP files
9
9
  Zip.warn_invalid_date = false
10
10
 
11
+ # Load config early, before dry-cli instantiates commands.
12
+ # This is necessary because command classes use Import which resolves
13
+ # dependencies at instantiation time (before CommandWrapper#call).
14
+ # Without this, cache backends would use default config instead of user config.
15
+ #
16
+ # Note: --config-path option is handled separately in CommandWrapper and
17
+ # will override settings if specified.
18
+ config_path_index = ARGV.index("--config-path") || ARGV.index("-c")
19
+ if config_path_index && ARGV[config_path_index + 1]
20
+ Factorix.load_config(Pathname(ARGV[config_path_index + 1]))
21
+ elsif ENV["FACTORIX_CONFIG"]
22
+ Factorix.load_config(Pathname(ENV.fetch("FACTORIX_CONFIG")))
23
+ else
24
+ default_config = Factorix::Container[:runtime].factorix_config_path
25
+ Factorix.load_config(default_config) if default_config.exist?
26
+ end
27
+
11
28
  begin
12
29
  Dry::CLI.new(Factorix::CLI).call
13
30
  exit 0
@@ -12,11 +12,9 @@ module Factorix
12
12
  # when FACTORIO_USERNAME/FACTORIO_TOKEN environment variables are not set.
13
13
  # It's resolved lazily via reader method instead.
14
14
  # @!parse
15
- # # @return [Transfer::Downloader]
16
- # attr_reader :downloader
17
15
  # # @return [Dry::Logger::Dispatcher]
18
16
  # attr_reader :logger
19
- include Import[:downloader, :logger]
17
+ include Import[:logger]
20
18
 
21
19
  BASE_URL = "https://mods.factorio.com"
22
20
  private_constant :BASE_URL
@@ -34,24 +32,31 @@ module Factorix
34
32
  # @param download_url [String] relative download URL from API response (e.g., "/download/mod-name/...")
35
33
  # @param output [Pathname] output file path
36
34
  # @param expected_sha1 [String, nil] expected SHA1 digest for verification (optional)
35
+ # @param handler [Object, nil] event handler for download progress (optional)
37
36
  # @return [void]
38
37
  # @raise [ArgumentError] if download_url is not a relative path starting with "/"
39
38
  # @raise [DigestMismatchError] if SHA1 verification fails
40
- def download(download_url, output, expected_sha1: nil)
39
+ def download(download_url, output, expected_sha1: nil, handler: nil)
41
40
  unless download_url.start_with?("/")
42
41
  logger.error("Invalid download_url", url: download_url)
43
42
  raise ArgumentError, "download_url must be a relative path starting with '/'"
44
43
  end
45
44
 
46
45
  uri = build_download_uri(download_url)
47
- downloader.download(uri, output, expected_sha1:)
46
+ downloader = Container[:downloader]
47
+ downloader.subscribe(handler) if handler
48
+ begin
49
+ downloader.download(uri, output, expected_sha1:)
50
+ ensure
51
+ downloader.unsubscribe(handler) if handler
52
+ end
48
53
  end
49
54
 
50
55
  private def service_credential
51
56
  return @service_credential if defined?(@service_credential)
52
57
 
53
58
  @service_credential_mutex.synchronize do
54
- @service_credential ||= Application[:service_credential]
59
+ @service_credential ||= Container[:service_credential]
55
60
  end
56
61
  end
57
62
 
@@ -114,7 +114,7 @@ module Factorix
114
114
 
115
115
  URI(value)
116
116
  rescue URI::InvalidURIError => e
117
- Application[:logger].warn("Skipping invalid URI '#{value}': #{e.message}")
117
+ Container[:logger].warn("Skipping invalid URI '#{value}': #{e.message}")
118
118
  nil
119
119
  end
120
120
  end
@@ -145,7 +145,7 @@ module Factorix
145
145
  Release[**r]
146
146
  rescue RangeError => e
147
147
  # Skip releases with invalid version numbers
148
- Application[:logger].warn("Skipping release #{name}@#{r[:version]}: #{e.message}")
148
+ Container[:logger].warn("Skipping release #{name}@#{r[:version]}: #{e.message}")
149
149
  nil
150
150
  end
151
151
  }
@@ -208,7 +208,7 @@ module Factorix
208
208
  return @api_credential if defined?(@api_credential)
209
209
 
210
210
  @api_credential_mutex.synchronize do
211
- @api_credential ||= Application[:api_credential]
211
+ @api_credential ||= Container[:api_credential]
212
212
  end
213
213
  end
214
214
 
@@ -2,7 +2,6 @@
2
2
 
3
3
  require "erb"
4
4
  require "json"
5
- require "tempfile"
6
5
  require "uri"
7
6
 
8
7
  module Factorix
@@ -86,13 +85,13 @@ module Factorix
86
85
 
87
86
  # Invalidate get_mod cache
88
87
  mod_uri = build_uri("/api/mods/#{encoded_name}")
89
- mod_key = cache.key_for(mod_uri.to_s)
90
- cache.with_lock(mod_key) { cache.delete(mod_key) }
88
+ mod_cache_key = mod_uri.to_s
89
+ cache.with_lock(mod_cache_key) { cache.delete(mod_cache_key) }
91
90
 
92
91
  # Invalidate get_mod_full cache
93
92
  full_uri = build_uri("/api/mods/#{encoded_name}/full")
94
- full_key = cache.key_for(full_uri.to_s)
95
- cache.with_lock(full_key) { cache.delete(full_key) }
93
+ full_cache_key = full_uri.to_s
94
+ cache.with_lock(full_cache_key) { cache.delete(full_cache_key) }
96
95
 
97
96
  logger.debug("Invalidated cache for MOD", mod: mod_name)
98
97
  end
@@ -101,55 +100,13 @@ module Factorix
101
100
  URI.join(BASE_URL, path).tap {|uri| uri.query = URI.encode_www_form(params.sort.to_h) unless params.empty? }
102
101
  end
103
102
 
104
- # Fetch data with cache support
103
+ # Fetch data from API (caching is handled by CacheDecorator in api_http_client)
105
104
  #
106
105
  # @param uri [URI::HTTPS] URI to fetch
107
106
  # @return [Hash{Symbol => untyped}] parsed JSON response with symbolized keys
108
107
  private def fetch_with_cache(uri)
109
- key = cache.key_for(uri.to_s)
110
-
111
- cached = cache.read(key, encoding: "UTF-8")
112
- if cached
113
- logger.debug("API cache hit", uri: uri.to_s)
114
- return JSON.parse(cached, symbolize_names: true)
115
- end
116
-
117
- logger.debug("API cache miss", uri: uri.to_s)
118
- response_body = fetch_from_api(uri)
119
-
120
- store_in_cache(key, response_body)
121
-
122
- JSON.parse(response_body, symbolize_names: true)
123
- end
124
-
125
- # Fetch data from API via HTTP
126
- #
127
- # @param uri [URI::HTTPS] URI to fetch
128
- # @return [String] response body
129
- # @raise [HTTPClientError] for 4xx errors
130
- # @raise [HTTPServerError] for 5xx errors
131
- private def fetch_from_api(uri)
132
- logger.info("Fetching from API", uri: uri.to_s)
133
108
  response = client.get(uri)
134
- logger.info("API response", code: response.code, size_bytes: response.body.bytesize)
135
- response.body
136
- end
137
-
138
- # Store response body in cache via temporary file
139
- #
140
- # @param key [String] cache key
141
- # @param data [String] response body
142
- # @return [void]
143
- private def store_in_cache(key, data)
144
- temp_file = Tempfile.new("cache")
145
- begin
146
- temp_file.write(data)
147
- temp_file.close
148
- cache.store(key, Pathname(temp_file.path))
149
- logger.debug("Stored API response in cache", key:)
150
- ensure
151
- temp_file.unlink
152
- end
109
+ JSON.parse((+response.body).force_encoding(Encoding::UTF_8), symbolize_names: true)
153
110
  end
154
111
 
155
112
  # Validate page_size parameter
@@ -22,7 +22,7 @@ module Factorix
22
22
  # @return [APICredential] new instance with API key from environment
23
23
  # @raise [CredentialError] if API key is not set in environment
24
24
  def self.load
25
- logger = Application["logger"]
25
+ logger = Container["logger"]
26
26
  logger.debug "Loading API credentials from environment"
27
27
 
28
28
  api_key = ENV.fetch(ENV_API_KEY, nil)
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Cache
5
+ # Abstract base class for cache backends.
6
+ #
7
+ # All cache backends (FileSystem, S3, Redis) inherit from this class
8
+ # and implement the abstract methods defined here.
9
+ #
10
+ # @abstract Subclasses must implement all abstract methods.
11
+ class Base
12
+ # @return [Integer, nil] time-to-live in seconds (nil for unlimited)
13
+ attr_reader :ttl
14
+
15
+ # Initialize a new cache backend.
16
+ #
17
+ # @param ttl [Integer, nil] time-to-live in seconds (nil for unlimited)
18
+ def initialize(ttl: nil)
19
+ @ttl = ttl
20
+ end
21
+
22
+ # Check if a cache entry exists and is not expired.
23
+ #
24
+ # @param key [String] logical cache key
25
+ # @return [Boolean] true if the cache entry exists and is valid
26
+ # @abstract
27
+ def exist?(key) = raise NotImplementedError, "#{self.class}#exist? must be implemented"
28
+
29
+ # Read a cached entry as a string.
30
+ #
31
+ # @param key [String] logical cache key
32
+ # @return [String, nil] cached content or nil if not found/expired
33
+ # @abstract
34
+ def read(key) = raise NotImplementedError, "#{self.class}#read must be implemented"
35
+
36
+ # Write cached content to a file.
37
+ #
38
+ # Unlike {#read} which returns content as a String, this method writes
39
+ # directly to a file path, which is more memory-efficient for large files.
40
+ #
41
+ # @param key [String] logical cache key
42
+ # @param output [Pathname] path to write the cached content
43
+ # @return [Boolean] true if written successfully, false if not found/expired
44
+ # @abstract
45
+ def write_to(key, output) = raise NotImplementedError, "#{self.class}#write_to must be implemented"
46
+
47
+ # Store data in the cache.
48
+ #
49
+ # @param key [String] logical cache key
50
+ # @param src [Pathname] path to the source file
51
+ # @return [Boolean] true if stored successfully
52
+ # @abstract
53
+ def store(key, src) = raise NotImplementedError, "#{self.class}#store must be implemented"
54
+
55
+ # Delete a cache entry.
56
+ #
57
+ # @param key [String] logical cache key
58
+ # @return [Boolean] true if deleted, false if not found
59
+ # @abstract
60
+ def delete(key) = raise NotImplementedError, "#{self.class}#delete must be implemented"
61
+
62
+ # Clear all cache entries.
63
+ #
64
+ # @return [void]
65
+ # @abstract
66
+ def clear = raise NotImplementedError, "#{self.class}#clear must be implemented"
67
+
68
+ # Execute a block with an exclusive lock on the cache entry.
69
+ #
70
+ # @param key [String] logical cache key
71
+ # @yield block to execute with lock held
72
+ # @abstract
73
+ def with_lock(key) = raise NotImplementedError, "#{self.class}#with_lock must be implemented"
74
+
75
+ # Get the age of a cache entry in seconds.
76
+ #
77
+ # @param key [String] logical cache key
78
+ # @return [Float, nil] age in seconds, or nil if entry doesn't exist
79
+ # @abstract
80
+ def age(key) = raise NotImplementedError, "#{self.class}#age must be implemented"
81
+
82
+ # Check if a cache entry has expired based on TTL.
83
+ #
84
+ # @param key [String] logical cache key
85
+ # @return [Boolean] true if expired, false otherwise
86
+ # @abstract
87
+ def expired?(key) = raise NotImplementedError, "#{self.class}#expired? must be implemented"
88
+
89
+ # Get the size of a cached entry in bytes.
90
+ #
91
+ # @param key [String] logical cache key
92
+ # @return [Integer, nil] size in bytes, or nil if entry doesn't exist/expired
93
+ # @abstract
94
+ def size(key) = raise NotImplementedError, "#{self.class}#size must be implemented"
95
+
96
+ # Enumerate cache entries.
97
+ #
98
+ # Yields [key, entry] pairs similar to Hash#each.
99
+ #
100
+ # @yield [key, entry] logical key and Entry object
101
+ # @yieldparam key [String] logical cache key
102
+ # @yieldparam entry [Entry] cache entry metadata
103
+ # @return [Enumerator] if no block given
104
+ # @abstract
105
+ def each = raise NotImplementedError, "#{self.class}#each must be implemented"
106
+
107
+ # Return backend-specific information.
108
+ #
109
+ # Subclasses should override this method to provide configuration details
110
+ # specific to their backend implementation.
111
+ #
112
+ # @return [Hash] backend-specific information (empty by default)
113
+ def backend_info = {}
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Factorix
4
+ module Cache
5
+ Entry = Data.define(:size, :age, :expired)
6
+
7
+ # Represents a cache entry for enumeration operations.
8
+ #
9
+ # Used by {Base#each} to yield entry metadata alongside keys.
10
+ # Note: The key is NOT included in Entry; it is yielded separately.
11
+ #
12
+ # @!attribute [r] size
13
+ # @return [Integer] entry size in bytes
14
+ # @!attribute [r] age
15
+ # @return [Float] age in seconds since creation/modification
16
+ class Entry
17
+ private :expired
18
+
19
+ # Check if the cache entry has expired.
20
+ #
21
+ # @return [Boolean] true if entry has exceeded TTL
22
+ def expired? = expired
23
+ end
24
+ end
25
+ end