Browse Source

monkey_patcher + live_constant

main
pvincent 1 month ago
parent
commit
53807b03c1
  1. 2
      app/views/layouts/application.html.erb
  2. 3
      app/views/scores/index.html.erb
  3. 20
      config/application.rb
  4. 17
      config/environments/development.rb
  5. 9
      config/initializers/hot_changes.rb
  6. 14
      config/initializers/monkey_patcher.rb
  7. 12
      config/initializers/monkey_patches.rb
  8. 13
      config/initializers/rails_live_reload.rb
  9. 21
      config/initializers/semantic_logger.rb
  10. 104
      lib/hot/constants.rb
  11. 8
      lib/hot/live.rb
  12. 13
      lib/live/constants.rb
  13. 118
      lib/live/definable.rb
  14. 17
      lib/monkey_patches/monkey_patcher.rb
  15. 29
      lib/monkey_patches/rails_live_reload/watcher.rb
  16. 4
      lib/semantic/basic_formatter.rb
  17. 76
      lib/semantic/dev_loader.rb
  18. 2
      lib/semantic/fancy_dimensions.rb
  19. 4
      lib/semantic/fancy_formatter.rb
  20. 39
      lib/semantic/notification_util.rb

2
app/views/layouts/application.html.erb

@ -14,7 +14,7 @@
<%= tag :meta, name: :viewport, content: 'width=device-width,initial-scale=1' %>
<% if Rails.env.development? %>
<%= tag :meta, name: 'turbo-prefetch', content: false %>
<%= tag :meta, name: 'stimulus-debug', content: Hot::Constants.stimulus_debug %>
<%= tag :meta, name: 'stimulus-debug', content: Live::Constants::STIMULUS_DEBUG %>
<% end%>
<!-- LINK-->

3
app/views/scores/index.html.erb

@ -2,6 +2,9 @@
List of Scores
</h1>
ACTION_CONTROLLER = <%=Live::Constants::ACTION_CONTROLLER%><br/>
ACTION_VIEW = <%=Live::Constants::ACTION_VIEW%><br/>
<div class="m-5 flex justify-end">
<%= link_to "New score", new_score_path, class: "border rounded-lg py-3 px-5 bg-blue-600 text-white block font-medium" %>
</div>

20
config/application.rb

@ -15,26 +15,10 @@ module EasyGoingRails
# Initialize configuration defaults for originally generated Rails version.
config.load_defaults 7.2
# Please, add to the `ignore` list any other `lib` subdirectories that do
# not contain `.rb` files, or that should not be reloaded or eager loaded.
# Common ones are `templates`, `generators`, or `middleware`, for example.
# config.autoload_lib(ignore: %w[assets tasks formatters hot_constants monkey_patches])
# config.autoload_lib(ignore: %w[assets tasks formatters monkey_patches])
config.autoload_lib(ignore: %w[assets tasks])
# ignoring monkey_patches because MonkeyPatcher would carry out
config.autoload_lib(ignore: %w[assets tasks monkey_patches])
# main application title defined from current module name, see module above
config.application_title = module_parent.to_s.titleize
# Configuration for the application, engines, and railties goes here.
#
# These settings can be overridden in specific environments using the files
# in config/environments, which are processed later.
#
# config.time_zone = "Central Time (US & Canada)"
# config.eager_load_paths << Rails.root.join("extras")
# Customized Semantic Logger
config.rails_semantic_logger.semantic = false
config.rails_semantic_logger.add_file_appender = false
end
end

17
config/environments/development.rb

@ -77,19 +77,8 @@ Rails.application.configure do # rubocop:disable Metrics/BlockLength
routes.default_url_options[:port] = ARGV[1] # ie: Procfile.dev --port PORT
routes.default_url_options[:host] = '127.0.0.1'
if Rails.application.console?
config.after_initialize do
formatter = Semantic::BasicFormatter.new
SemanticLogger.add_appender(io: $stdout, formatter:)
# FIXME: filter is useful!
# SemanticLogger.add_appender(io: $stdout,
# formatter:,
# filter: ->(log) { !formatter.reject(log) })
end
elsif Rails.application.server?
config.after_initialize do
config.dev_loader = Semantic::DevLoader.new('toto1')
end
Rails.configuration.after_initialize do
# SemanticLogger.add_appender(io: $stdout, formatter: Semantic::BasicFormatter.new)
SemanticLogger.add_appender(io: $stdout, formatter: Semantic::FancyFormatter.new)
end
end

9
config/initializers/hot_changes.rb

@ -1,9 +0,0 @@
return unless Rails.application.server?
Rails.application.config.after_initialize do
Hot::Constants.on_change(:log_active_record) { |bool| ActiveRecord::Base.logger.level = bool ? :debug : :fatal }
Hot::Constants.on_change(:log_action_view) do |bool|
ActionView::Base.logger.level = bool ? :debug : :fatal
Rails.application.config.dev_loader.launch
end
end

14
config/initializers/monkey_patcher.rb

@ -1,14 +0,0 @@
return unless Rails.application.server?
# puts 'MonkeyPatcher runs:'
patches = Dir.glob(Rails.root.join('lib', 'monkey_patches', '**', '*.rb'))
patches.each do |file|
# puts "🐵 patching... #{Pathname.new(file).relative_path_from Rails.root}"
require file
end
# puts case patches.count
# when 0 then 'No patch found'
# when 1 then '1 successful patch applied'
# else "#{patches.count} successful patches applied"
# end

12
config/initializers/monkey_patches.rb

@ -0,0 +1,12 @@
return unless Rails.application.server? || Rails.application.console?
require_relative '../../lib/monkey_patches/monkey_patcher'
MonkeyPatcher.run do |patch|
case patch
when 'action_dispatch/middleware/debug_exceptions.rb' then Rails.application.server?
when 'rails_live_reload/watcher.rb' then Rails.application.server? && Rails.env.development?
when /^semantic/ then true
else false
end
end

13
config/initializers/rails_live_reload.rb

@ -1,16 +1,11 @@
return unless defined?(RailsLiveReload) && Rails.env.development?
# RailsLiveReload
RailsLiveReload.configure do |config|
# HOT Constants
config.watch(%r{/\.env$}, reload: :always)
config.watch(%r{/\.env\.sample$})
# config.watch(%r{/config/initializers/hot_changes.rb$})
# USEFUL for tailwind changes!!!!
config.watch %r{app/assets/builds/tailwind.css}, reload: :always
# Rk: prevent any reload from files ending with '.tailwind.css'
# Tailwind CSS
# First rule prevents reloading from asset files ending with '.tailwind.css'
config.watch %r{(app|vendor)/(assets|javascript)/.+\.(css|js|html|png|jpg)(?<!tailwind\.css)$}, reload: :always
config.watch %r{app/assets/builds/tailwind.css}, reload: :always
# Default watched folders & files
config.watch %r{app/views/.+\.(erb|haml|slim)$}

21
config/initializers/semantic_logger.rb

@ -0,0 +1,21 @@
RailsSemanticLogger::Rack::Logger.logger.level = :info # useful for remaining log like "[Rack::Log] Started..."
SemanticLogger.clear_appenders!
# Zeitwerk reload message
Rails.autoloaders.main.on_load('ApplicationController') { SemanticLogger[:Zeitwerk].debug('reload!') }
all_notifications = {
action_controller: %i[start_processing process_action redirect_to],
action_view: %i[render_partial render_template render_collection render_layout],
active_record: %i[sql strict_loading instantiation start_transaction transaction]
}
Rails.configuration.after_initialize do
all_notifications.each do |event_group, hooks|
hooks.each { |hook| ActiveSupport::Notifications.unsubscribe("#{hook}.#{event_group}") }
end
ActiveSupport::Notifications.subscribe('rolling.live_constant') do |event|
SemanticLogger[:live_notifications].warn('new event', event.payload) # FIXME: to be continued...
end
end

104
lib/hot/constants.rb

@ -1,104 +0,0 @@
require 'dotenv'
module Hot
# Hot Live Constants
module Constants
include SemanticLogger::Loggable
HOTENV = {} # rubocop:disable Style/MutableConstant
LISTENERS = {} # rubocop:disable Style/MutableConstant
class << self
def initialize
@old_definitions = {}
@hot_definitions = {}
logger.level = :fatal
load_definitions
load_values
logger.level = :info
end
def load_definitions
logger.debug('--load_definitions3')
new_definitions = Dotenv.parse('.env.sample')
definitions_to_actualize = {}
@old_definitions.merge(new_definitions) do |key, old_value, new_value|
definitions_to_actualize[key] = new_value if new_value != old_value
end
definitions_to_actualize.each { |key, value| define_method(:actualize, key, value) }
@old_definitions = new_definitions
definitions_to_remove = @hot_definitions.except(*new_definitions.keys)
definitions_to_remove.each_pair do |key, dkey|
logger.info("remove method <#{dkey}>")
singleton_class.undef_method(dkey)
@hot_definitions.delete(key)
end
definitions_to_add = new_definitions.except(*@hot_definitions.keys)
definitions_to_add.each { |key, value| define_method(:add, key, value) }
end
def load_values
logger.debug('--load_values')
new_env = Dotenv.parse
constants_to_delete = HOTENV.except(*new_env.keys)
constants_to_delete.each do |name, _|
# FIXME: default should read default and type from @old_definitions
type, default = @old_definitions[name]
logger.info("constant <#{name}> reverts to default value <#{default}> of type <#{type}>")
end
constants_to_add = new_env.except(*HOTENV.keys)
constants_to_add.each do |constant|
logger.info("constant to add <#{constant}>")
end
HOTENV.replace new_env
LISTENERS.each_pair { |k, b| perform_change(k, b) }
end
def on_change(key, &block)
LISTENERS[key.downcase.to_sym] = block
perform_change(key, block)
end
private
def define_method(mode, key, value)
dkey = key.downcase
method = infer_method_from_value(value)
inferred_type = method.name.to_s.split('_')[1]
logger.info("#{mode} method <#{dkey}> of type <#{inferred_type}> with default value <#{value}> ")
singleton_class.define_method(dkey) { method.call(key, value) }
@hot_definitions.store(key, dkey)
end
def perform_change(key, block)
old_value = nil # TODO: remember last previous value
new_value = method(key).call
block.call(new_value, old_value)
end
def load_boolean(key, default) = HOTENV.fetch(key, default).to_s.downcase == 'true'
def load_integer(key, default) = HOTENV.fetch(key, default).to_i
def load_string(key, default) = HOTENV.fetch(key, default)
def infer_method_from_value(value)
case value.downcase
when /^(true|false)$/ then method(:load_boolean)
when /^\d+$/ then method(:load_integer)
else method(:load_string)
end
end
end
initialize # done once on first require, cause this is just a module (not a class!)
end
end

8
lib/hot/live.rb

@ -1,8 +0,0 @@
module Hot
# Hot Live constants
class Live
def initialize
puts 'Hot Live constants initialized'
end
end
end

13
lib/live/constants.rb

@ -0,0 +1,13 @@
module Live
# My live constants
module Constants
extend Definable
STIMULUS_DEBUG = boolean false
ACTION_VIEW = boolean true
ACTION_CONTROLLER = boolean true
MY_INTEGER = integer 8
MY_STRING = string 'titi'
end
end

118
lib/live/definable.rb

@ -0,0 +1,118 @@
require 'dotenv'
$definable_thread_group ||= ThreadGroup.new
module Live
# offers typed constant defintions with default value, by using lots of introspecting...
module Definable
MAIN_CSS = 'app/assets/stylesheets/application.css'.freeze # useful to trigger :reload_all from RailsLiveReload
def integer(default = 0) = define_type_from_callee(caller[0], :integer, default)
def boolean(default = true) = define_type_from_callee(caller[0], :boolean, default) # rubocop:disable Style/OptionalBooleanParameter
def string(default = '') = define_type_from_callee(caller[0], :string, default)
def reload_from_env
logger.debug('reload from env')
@env_values = Dotenv.parse(*Dotenv::Rails.files)
changes = 0
prefix = 'Constant'
env_values.each_pair do |constant, raw|
next unless definitions.include?(constant)
value = typed_value(definitions[constant][:type], raw, definitions[constant][:default])
next unless value != definitions[constant][:value]
define_value(constant, value)
logger.warn "#{prefix} overriden from environment:#{Semantic::AnsiColors::CLEAR} #{constant} = #{value.ai}"
changes += 1
end
definitions.except(*env_values.keys).each_pair do |constant, options|
default = options[:default]
next unless options[:value] != default
define_value(constant, default)
logger.warn "#{prefix} restored:#{Semantic::AnsiColors::CLEAR} #{constant} = #{default.ai}"
changes += 1
end
return unless changes.positive?
# TODO: ...
# changes=[]
# changes << {kind: :overriden, constant: 'ACTION_VIEW', type :boolean, old_value: false, new_value:false}
# changes << {kind: :restored, constant: 'ACTION_VIEW', type :boolean, old_value: false, new_value:false}
# ActiveSupport::Notifications.instrument 'rolling.live_constant', this: changes
return unless RailsLiveReload.watcher
FileUtils.touch(MAIN_CSS) # triggering RailsLiveReload
end
private
def logger = @logger ||= SemanticLogger[self]
def env_values = @env_values ||= Dotenv.parse
def definitions = @definitions ||= {}
def define_value(constant, value)
definitions[constant][:value] = value
remove_const(constant)
const_set(constant, value)
end
# origin (or caller[0]) helps fetching the constant name from source code introspection
def define_type_from_callee(origin, type, default)
@@class_origin ||= self # rubocop:disable Style/ClassVars
@listener ||= start_listener
file, line = origin.split(':')
constant = introspect_constant_from_file(file, line.to_i - 1)
raw_value = env_values.fetch(constant, nil)
value = typed_value(type, raw_value, default)
definitions[constant] = { type:, default:, value: }
# logger.debug('new definitions', definitions)
value
end
def start_listener
$definable_thread_group.list.each(&:kill)
$definable_thread_group.add(Thread.new do
listener = Listen.to(Rails.root, only: /^\.env\.?/) do
@@class_origin.reload_from_env
rescue StandardError
nil
end
listener.start
end)
end
def typed_value(type, raw, default)
return default if raw.nil?
case type
when :integer then raw.to_i
when :boolean then raw.upcase == 'TRUE'
else raw
end
end
# returns current directory of this source code
def dir_source_location
return @dir_source_location if defined?(@dir_source_location)
*paths, _ = Live.const_source_location(:Definable).first.split('/')
@dir_source_location = paths.join('/')
end
def introspect_constant_from_file(file, line)
*dir_file, _ = file.split('/')
dir_file = dir_file.join('/')
raise "unexpected directory: #{dir_file} != #{dir_source_location}" unless dir_file == dir_source_location
@lines ||= File.readlines(file) # cached source code
@lines[line].match(/\s*(.\w+)/)[1] # TODO: should be uppercase!
end
end
end

17
lib/monkey_patches/monkey_patcher.rb

@ -0,0 +1,17 @@
class MonkeyPatcher
class << self
MONKEY_PATCHES = Rails.root.join('lib', 'monkey_patches')
def run(&)
patches = Dir.glob(MONKEY_PATCHES.join('**', '*.rb'))
patches.each do |patch|
file = Pathname.new(patch).relative_path_from(MONKEY_PATCHES).to_s
next if file == 'monkey_patcher.rb' # filter off its own file!
if !block_given? || yield(file)
puts "🐵 patching... #{file}"
require file
end
end
end
end
end

29
lib/monkey_patches/rails_live_reload/watcher.rb

@ -1,9 +1,4 @@
module RailsLiveReload
ENV_FILE = Rails.root.join('.env').to_s
ENV_SAMPLE_FILE = Rails.root.join('.env.sample').to_s
INITIALIZER = Rails.root.join('config/initializers/hot_changes.rb').to_s
CHECKSUMS = {} # rubocop:disable Style/MutableConstant
# MonkeyPath Watcher
class Watcher
def initialize
@ -19,29 +14,5 @@ module RailsLiveReload
start_socket
start_listener
end
def reload_all
before_reload(files)
data = { event: RailsLiveReload::INTERNAL[:socket_events][:reload], files: }.to_json
@sockets.each { |socket, _| socket.puts data } # rubocop:disable Style/HashEachMethods
end
private
def before_reload(files)
perform_when_change(files, ENV_SAMPLE_FILE) { Hot::Constants.load_definitions }
perform_when_change(files, ENV_FILE) { Hot::Constants.load_values }
perform_when_change(files, INITIALIZER) { load Rails.root.join('config', 'initializers', 'hot_changes.rb') }
end
def perform_when_change(files, key, &)
return unless files.include?(key)
current_checksum = files[key]
return unless current_checksum != CHECKSUMS[key]
yield
CHECKSUMS[key] = current_checksum
end
end
end

4
lib/semantic/basic_formatter.rb

@ -4,7 +4,6 @@ module Semantic
ANSI_REVERSED_WARNING = "\e[0;30;43m".freeze
ANSI_REVERSED_ERROR = "\e[1;30;41m".freeze
ANSI_GRAY = "\e[90m".freeze
# Return the complete log level name in uppercase
def initialize
super(time_format: '%H:%M:%S',
@ -75,7 +74,8 @@ module Semantic
self.logger = logger
if @time_format
[time, level, process_info, tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
[time, level, process_info, tags, named_tags, duration, name, message, payload,
exception].compact.join(' ')
else
[tags, named_tags, duration, name, message, payload, exception].compact.join(' ')
end

76
lib/semantic/dev_loader.rb

@ -1,76 +0,0 @@
module Semantic
# use the Zeitwerk autoloader to reattach_appender for development autoreloading feature
class DevLoader
def initialize(session_key)
@session_key = session_key
@subscribers = {}
RailsSemanticLogger::ActionController::LogSubscriber.logger.level = :fatal # useful for remanent Rack::Log started
launch
end
def launch
once_and_reload do
append_ansi_formatter
Semantic::NotificationUtil.clear_subscribers(/\.action_controller$/)
Semantic::NotificationUtil.clear_subscribers(/\.action_view$/)
reset_subscribers
register_action_controller
register_action_view
end
end
private
def once_and_reload(&)
yield
Rails.autoloaders.main.on_load('ApplicationController', &)
end
def append_ansi_formatter
SemanticLogger.clear_appenders!
formatter = Semantic::AnsiFormatter.new
SemanticLogger.add_appender(io: $stdout,
formatter:,
filter: ->(log) { !formatter.reject(log) })
end
def register_action_controller
sub_instance = Semantic::Subscribers::ActionController.new(@session_key)
register_hook(sub_instance, :start_processing)
register_hook(sub_instance, :process_action, :finish_processing)
register_hook(sub_instance, :redirect_to)
%i[send_file send_data halted_callback unpermitted_parameters send_stream write_fragment
read_fragment expire_fragment exist_fragment?].each do |hook|
register_hook(sub_instance, hook, :any_hook)
end
end
def register_action_view
sub_instance = Semantic::Subscribers::ActionView.new
%i[render_template render_partial render_collection render_layout].each do |hook|
register_hook(sub_instance, hook)
end
end
def register_hook(sub_instance, hook, method = hook)
@subscribers[sub_instance.class] ||= []
@subscribers[sub_instance.class] << ActiveSupport::Notifications.subscribe("#{hook}.#{sub_instance.event_group}") do |event|
sub_instance.send(method, event)
end
end
def reset_subscribers
return if @subscribers.empty?
@subscribers.each_pair do |clazz, subs|
# puts "reset #{subs.size} subscribers for class <#{clazz}>"
subs.each { |sub| ActiveSupport::Notifications.unsubscribe(sub) }
subs.clear
end
end
end
end

2
lib/semantic/ansi_dimensions.rb → lib/semantic/fancy_dimensions.rb

@ -2,7 +2,7 @@ require 'ostruct'
module Semantic
# extra dimensions for customizing the logging format
module AnsiDimensions
module FancyDimensions
def self.new(rails: '╣x╠', before: 0, after: 0, terminus: false)
OpenStruct.new(rails:, before:, after:, terminus:) # rubocop:disable Style/OpenStructUse
end

4
lib/semantic/ansi_formatter.rb → lib/semantic/fancy_formatter.rb

@ -4,7 +4,7 @@ require 'json'
module Semantic
# wraps meanwhile takes care of ansi colors
class AnsiFormatter < SemanticLogger::Formatters::Color
class FancyFormatter < SemanticLogger::Formatters::Color
include AnsiColors
TAG_NONE = ''.freeze
@ -179,7 +179,7 @@ module Semantic
clazz = colorize("#{exc.class}\n", color_map[:fatal])
message = colorize(exc.message.chomp(''), color_map[:error])
backtrace = stackisize(Rails.backtrace_cleaner.clean(exc.backtrace)) # TODO: backtrace_cleaner might be optionally disable from HotConstant
backtrace = stackisize(Rails.backtrace_cleaner.clean(exc.backtrace)) # TODO: backtrace_cleaner might be optionally disable from Live::Constant
"#{clazz}#{message}#{backtrace}"
end

39
lib/semantic/notification_util.rb

@ -1,39 +0,0 @@
module Semantic
module NotificationUtil
class << self
# pattern could be either a string 'start_processing.action_controller' or a regex /\.action_controller$/
# FIXME: weird behaviour, order impact!!!!
# For instance:
# OK
# NotificationUtil.clear_subscribers(/\.action_controller$/)
# NotificationUtil.clear_subscribers(/\.action_view$/)
# NOPE
# NotificationUtil.clear_subscribers(/\.action_view$/)
# NotificationUtil.clear_subscribers(/\.action_controller$/)
def clear_subscribers(pattern)
ActiveSupport::LogSubscriber.subscribers.each { |sub| unattach(sub, pattern) }
end
private
def subscriber_patterns(subscriber)
subscriber.patterns.respond_to?(:keys) ? subscriber.patterns.keys : subscriber.patterns
end
def unattach(subscriber, pattern)
subscriber_patterns(subscriber).each do |sub_pattern|
ActiveSupport::Notifications.notifier.listeners_for(sub_pattern).each do |sub|
next unless sub.instance_variable_get(:@delegate) == subscriber
next unless pattern.match(sub_pattern)
puts "FOUND subscriber=#{subscriber} for sub_pattern=#{sub_pattern} with logger #{subscriber.logger.name}"
puts subscriber.class.module_parent.const_source_location(subscriber.class.to_s)&.first
ActiveSupport::Notifications.unsubscribe(sub)
end
end
# ActiveSupport::LogSubscriber.subscribers.delete(subscriber)
end
end
end
end
Loading…
Cancel
Save