Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Undefine VERSION in gemspec to avoid namespace pollution between multiple MemoWises #370

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

JacobEvelyn
Copy link
Member

This commit updates memo_wise.gemspec to undefine the MemoWise::VERSION constant after it is read, as first suggested in
simplecov-ruby/simplecov#557 (comment).

Before this change, accessing MemoWise::VERSION immediately after the doff_and_don call in benchmarks.rb gives the version of the gem pulled from GitHub, even though doff_and_don should mean nothing is in the MemoWise namespace. After this change, accessing MemoWise::VERSION there gives this error as we expect:

uninitialized constant MemoWise (NameError)

Before merging:

  • Copy the table printed at the end of the latest benchmark results into the README.md and update this PR
  • If this change merits an update to CHANGELOG.md, add an entry following Keep a Changelog guidelines with semantic versioning

Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice :)

@pboling pboling mentioned this pull request Dec 23, 2024
2 tasks
Copy link

codecov bot commented Dec 23, 2024

Codecov Report

All modified and coverable lines are covered by tests ✅

Project coverage is 100.00%. Comparing base (5f8d31d) to head (642ee28).

Additional details and impacted files
@@            Coverage Diff            @@
##              main      #370   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files            2         3    +1     
  Lines          190       192    +2     
  Branches        90        90           
=========================================
+ Hits           190       192    +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update...

Thinking about this more. This could introduce a bug where another require / require_relative will not have the effect of the laoding the file because it thinks the file has already been loaded, and it doesn't know that you've removed the constant... thus the VERSION constant may never get loaded.

I do think it should be load here.

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Basically, you want to avoid the "tracking" side effect of require because you're going to remove the VERSION constant, and thus want other requires to work normally in all circumstances.

@JacobEvelyn
Copy link
Member Author

This could introduce a bug where another require / require_relative will not have the effect of the laoding the file because it thinks the file has already been loaded, and it doesn't know that you've removed the constant... thus the VERSION constant may never get loaded.

@pboling that's what I expected to happen, but I wasn't able to reproduce it locally. I suspect I had a bug in my setup though—are you able to reproduce the issue you described?

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Ooooh. Looking into this more deeply...

require_relative does not cache the loaded file like require. This means that if you use require_relative multiple times in a single program, Ruby will reload the file each time, even if it was previously loaded.

(According to AI Llama 2.5 4B 🗡️ )

Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should be fine with require_relative. 💯

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

I do think there is a cognitive benefit to load, as it is very clearly an indication that you want the load to be not require. It is a hard break from the require paradigm, where most people, including me, only think of the difference between require and require_relative, as a performance benefit by avoiding the load path, and not about the difference in tracking, which I'd forgotten about.

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

And that's quite enough bike shedding from me. 👯

@JacobEvelyn
Copy link
Member Author

JacobEvelyn commented Dec 23, 2024

require_relative does not cache the loaded file like require. This means that if you use require_relative multiple times in a single program, Ruby will reload the file each time, even if it was previously loaded.

This is false. I just tried using require_relative to load the same file twice, and the second time it returned false and did not load a new constant I added to the file in between require_relative calls. So I think the concern still is valid; I just would like to be able to reproduce it with MemoWise before switching to load. Does that make sense?

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Wow. So AI lied to me. :). Glad to know my original thinking was correct. I now remember when I operationalized the load paradigm for my gems I wanted to use require_relative, and tried to make that work, but I couldn't, but most of that experience is lost in the fog of time. I think I can create an example to demostrate the issue...

@JacobEvelyn
Copy link
Member Author

For context, I tried reproducing the issue with this Gemfile:

# frozen_string_literal: true

source "https://rubygems.org"

git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby ">= 2.7.7"

gem "memo_wise", github: "panorama-ed/memo_wise", branch: "version-undefined"

and this test Ruby file:

require 'memo_wise'

puts MemoWise::VERSION

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Yep. Playing with this myself, and I think there are two paths.

First, the paranoid path, which is what I've started down, but haven't committed to:

# final param `true` wraps the loaded namespace in an anonymous module,
#   making the constant collision impossible.
# downside is it uses a $LOAD_PATH search, and the inherent performance penalty.
load("lib/gem_name/version.rb", true)

Second, the hopeful path:

# works fine, so long as the gem's other lib files, and other tools,
#   always use require / require_relative to also load the version file.
# upside is it does not use the $LOAD_PATH search, so has a performance benefit.
require_relative "lib/gem_name/version"

I can't fault either choice. I've not used the wrap parameter of load in this context yet, but I think I may start trying it out.

@JacobEvelyn
Copy link
Member Author

@pboling can you explain what you mean by

so long as the gem's other lib files, and other tools, always use require / require_relative to also load the version file.

I don't think I follow.

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

require / require_relative track what is loaded, so they will be careful to not reload the same file twice. If version.rb is subsequently loaded by load then you'll get the warning constant already defined noise, assuming you don't remove the constant as this PR does.

I didn't make the point very clearly that I was going for, which is this:

I think with the final wrap parameter load(path, true), we wouldn't need to remove the constant.

That's what I'm going to try out now.

@JacobEvelyn
Copy link
Member Author

@pboling gotcha, so something like this?

Module.new.tap { |mod| load("lib/memo_wise/version.rb", mod) }::MemoWise::VERSION

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Recent versions of Ruby are able to pass a module, instead of a simple true/false wrap param, ever since this PR: ruby/ruby#4986
Unfortunately most of my libraries support older versions of Ruby still, so that's not useful for me yet. Prior to that feature, the only way to get access to the anonymous module that wrapped was a complex throw catch that straddled both the loading and the loaded files.

https://users.aalto.fi/~tontti/posts/ruby-by-examples/ruby-catching-anonymous-module/

So, my load with wrap param idea isn't viable for libraries that need old Ruby support. I suppose there isn't a good reason to not use require_relative, and I have a lot of code I should update.

Thanks for this discussion!

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

@JacobEvelyn only saw your response after my previous reply, but yes. That will work for gems that target recent versions of Ruby only.

require_relative "lib/memo_wise/version"
gem_version = MemoWise.send(:remove_const, :VERSION)
Copy link
Contributor

@pboling pboling Dec 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So the problem now is if using require_relative, and then removing the constant, the file will not be reloaded later, because we've determined that it does track / does not reload, and the constant will remain removed. I'm not sure how this could work @JacobEvelyn

Copy link
Contributor

@pboling pboling Dec 23, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess I'm full circle to: it needs to use load, and can optionally use the wrap param if on a new enough version of Ruby.

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Thinking about this more... still. You made this PR so that you could isolate the versions of libraries in the same namespace.

Your solution is to remove the constant. This works, however, the constant remains removed, and the version.rb file will not be reloaded by the gem. This means that MemoWise::VERSION should remain undefined later.

Your example in this comment is working but only because when you load the gem with require 'memo_wise' it doesn't load the gemspec... I think.

But I expect that there are scenarios where the gemspec and the gem could both be loaded, and in those scenarios, it could break, by not having a VERSION anymore, if something tried to reference it.

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Aha!

https://bundler.io/guides/bundler_in_a_single_file_ruby_script.html

The example for Bundler inline is exactly this type of scenario which breaks because the gemspec and the gem are loaded in the same context!

Below I've only changed it to reference MemoWise, but otherwise it is the same as the canonical example from bundler.io linked above.

require 'bundler/inline'

gemfile do
  source "https://rubygems.org"
  git_source(:github) { |repo| "https://github.com/#{repo}.git" }
  gem "memo_wise", github: "panorama-ed/memo_wise", branch: "version-undefined"
end

puts 'Gems installed and loaded!'
puts "MemoWise Version is #{MemoWise::VERSION}"

The result of running this:

❯ ruby main.rb
Gems installed and loaded!
main.rb:10:in `<main>': uninitialized constant MemoWise::VERSION (NameError)

puts "MemoWise Version is #{MemoWise::VERSION}"
                                    ^^^^^^^^^

It blows up because it is relying on the require "memo_wise" that bundler does internally for all gems that do not have require: false appended.

If you use load + remove constant in the gemspec instead, it will work 👍

@JacobEvelyn
Copy link
Member Author

Hmm @pboling when I try your code above (with load) I get:

/Users/jevelyn/.gem/ruby/3.3.3/gems/bundler-2.4.6/lib/bundler.rb:635:in `rescue in eval_gemspec':  (Bundler::GemspecError)
[!] There was an error while loading `memo_wise.gemspec`: cannot load such file -- lib/memo_wise/version. Bundler cannot continue.

 #  from /Users/jevelyn/.gem/ruby/3.3.3/bundler/gems/memo_wise-b06f1c6964e3/memo_wise.gemspec:17
 #  -------------------------------------------
 #  #   end::MemoWise::VERSION
 >  load "lib/memo_wise/version"
 #  gem_version = MemoWise.send(:remove_const, :VERSION)

Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@pboling
Copy link
Contributor

pboling commented Dec 23, 2024

Typo. load requires the file extension .rb.

# spec.version = Module.new.tap do |mod|
# load("lib/memo_wise/version.rb", mod)
# end::MemoWise::VERSION
load "lib/memo_wise/version"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
load "lib/memo_wise/version"
load "lib/memo_wise/version.rb"

…iple MemoWises

This commit updates `memo_wise.gemspec` to undefine the `MemoWise::VERSION`
constant after it is read, as first suggested in
simplecov-ruby/simplecov#557 (comment).

Before this change, accessing `MemoWise::VERSION` immediately after the
`doff_and_don` call in `benchmarks.rb` gives the version of the gem
pulled from GitHub, even though `doff_and_don` should mean nothing is in
the `MemoWise` namespace. After this change, accessing `MemoWise::VERSION`
there gives this error as we expect:

```
uninitialized constant MemoWise (NameError)
```
Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Copy link
Contributor

@pboling pboling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants