carton 0.1.0 → 0.3.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +92 -1
  3. data/README.md +103 -27
  4. data/lib/carton/activate_gems.rb +8 -0
  5. data/lib/carton/box.rb +162 -68
  6. data/lib/carton/bundler.rb +73 -0
  7. data/lib/carton/error.rb +24 -0
  8. data/lib/carton/gem_activation.rb +151 -0
  9. data/lib/carton/import_cache.rb +105 -0
  10. data/lib/carton/kernel_patch.rb +12 -8
  11. data/lib/carton/loaded_features.rb +10 -0
  12. data/lib/carton/namespace.rb +78 -0
  13. data/lib/carton/{export_methods.rb → namespace_methods.rb} +14 -3
  14. data/lib/carton/resolver.rb +149 -0
  15. data/lib/carton/runtime.rb +74 -59
  16. data/lib/carton/version.rb +1 -1
  17. data/lib/carton.rb +13 -22
  18. data/sig/carton.rbs +49 -0
  19. metadata +20 -34
  20. data/AGENTS.md +0 -47
  21. data/DESIGN.md +0 -84
  22. data/Rakefile +0 -52
  23. data/TODO.md +0 -33
  24. data/USAGE.md +0 -173
  25. data/examples/complex/README.md +0 -33
  26. data/examples/complex/cartons/adventure/.env +0 -1
  27. data/examples/complex/cartons/adventure/Gemfile +0 -5
  28. data/examples/complex/cartons/adventure/Gemfile.lock +0 -17
  29. data/examples/complex/cartons/adventure/lib/adventure.rb +0 -12
  30. data/examples/complex/cartons/loot/.env +0 -1
  31. data/examples/complex/cartons/loot/Gemfile +0 -5
  32. data/examples/complex/cartons/loot/Gemfile.lock +0 -17
  33. data/examples/complex/cartons/loot/lib/loot.rb +0 -47
  34. data/examples/complex/cartons/quest/lib/quest.rb +0 -6
  35. data/examples/complex/main.rb +0 -37
  36. data/examples/minimal/README.md +0 -16
  37. data/examples/minimal/bar.rb +0 -10
  38. data/examples/minimal/baz.rb +0 -7
  39. data/examples/minimal/foo.rb +0 -7
  40. data/examples/minimal/main.rb +0 -17
  41. data/images/carton.png +0 -0
  42. data/lib/carton/exports.rb +0 -35
data/USAGE.md DELETED
@@ -1,173 +0,0 @@
1
- # Usage
2
-
3
- ## Requirements
4
-
5
- - Ruby 4.0+
6
- - `RUBY_BOX=1`
7
- - `require 'carton'` before using `import`, `import_relative`, `export_default`, or `export`
8
-
9
- In Carton, an importable package is called a carton. This guide still says "package" sometimes in plain English, but "carton" is the project term.
10
-
11
- ## Exporting
12
-
13
- ### Single export
14
-
15
- Use a single export when the file represents one main thing.
16
-
17
- ```ruby
18
- # user.rb
19
- class User
20
- def initialize(name) = @name = name
21
-
22
- def greet = "Hello, #{@name}!"
23
- end
24
-
25
- export_default User
26
- ```
27
-
28
- Importing that file returns the exported object itself:
29
-
30
- ```ruby
31
- User = import_relative 'user'
32
- User.new('Alice').greet
33
- ```
34
-
35
- ### Named exports
36
-
37
- Use keyword arguments when the file exports a small namespace.
38
-
39
- ```ruby
40
- # math.rb
41
- def add(a, b) = a + b
42
-
43
- export(
44
- PI: 3.14159,
45
- version: '1.0.0',
46
- add: method(:add)
47
- )
48
- ```
49
-
50
- Importing named exports returns a `Carton::Exports` object, which behaves like a namespace module.
51
-
52
- ```ruby
53
- MathTools = import_relative 'math'
54
-
55
- MathTools::PI
56
- MathTools.version
57
- MathTools.add(2, 3)
58
- ```
59
-
60
- `Carton::Exports` and bare `Carton::Box` imports share a small lookup API: `[]`, `fetch`, `fetch_values`, `values_at`, and `key?`.
61
-
62
- Only one export call is allowed per imported file. Use `export_default value` for single exports or `export foo:, bar:` for named exports.
63
-
64
- ## Importing
65
-
66
- ### `import_relative`
67
-
68
- `import_relative path` resolves relative to the calling file, like `require_relative`.
69
-
70
- ```ruby
71
- User = import_relative 'user'
72
- ```
73
-
74
- ### `import`
75
-
76
- `import path` resolves using the current box's `$LOAD_PATH` or an absolute path.
77
-
78
- ```ruby
79
- Quest = import 'quest'
80
- Widget = import '/absolute/path/to/widget.rb'
81
- ```
82
-
83
- When you want `import 'name'` to work across local cartons, add their `lib/` directories to `$LOAD_PATH` first.
84
-
85
- ```ruby
86
- cartons_dir = File.expand_path('cartons', __dir__)
87
-
88
- Dir.glob(File.join(cartons_dir, '*/lib')).sort.each do |dir|
89
- $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir)
90
- end
91
- ```
92
-
93
- The complex example keeps that setup explicit in `examples/complex/main.rb`.
94
-
95
- ## Destructuring
96
-
97
- Named exports support pattern matching through `deconstruct_keys`.
98
-
99
- ```ruby
100
- import_relative('math') => { add:, version: }
101
-
102
- add.(2, 3)
103
- version
104
- ```
105
-
106
- You can rename during destructuring:
107
-
108
- ```ruby
109
- import_relative('math') => { add: sum }
110
- sum.(10, 10)
111
- ```
112
-
113
- ## Constants
114
-
115
- For exported constants, the simplest options are namespace access, `[]`, or `fetch`.
116
-
117
- ```ruby
118
- MathTools = import_relative 'math'
119
- PI = MathTools::PI
120
- ```
121
-
122
- ```ruby
123
- PI = import('math')[:PI]
124
- ```
125
-
126
- For multiple values:
127
-
128
- ```ruby
129
- PI, version = import_relative('math').fetch_values(:PI, :version)
130
- ```
131
-
132
- ## Bare imports
133
-
134
- If a file does not call `export`, importing it returns the `Carton::Box` itself.
135
-
136
- ```ruby
137
- Toolbox = import 'some_script'
138
-
139
- Toolbox::SomeConstant
140
- Toolbox.fetch(:helper)
141
- ```
142
-
143
- ## Bundler inside cartons
144
-
145
- The library works with or without Bundler. Plain cartons need no extra setup. For a bundled carton, the reliable pattern today is still: select the Gemfile around the `import` that loads that carton.
146
-
147
- ```ruby
148
- gemfile = File.expand_path('cartons/adventure/Gemfile', __dir__)
149
-
150
- Adventure = Carton.with_bundle(gemfile) { import 'adventure' }
151
- ```
152
-
153
- `Carton.with_bundle` is just a small wrapper around the same `BUNDLE_GEMFILE` handoff. The underlying constraint is still Bundler's: `bundler/setup` discovers the active Gemfile from env/process state, and RubyGems activation state (`Gem.loaded_specs`) is still shared across boxes in practice, so conflicting bundles still cannot be switched reliably in one process today.
154
-
155
- Current limits:
156
-
157
- - one carton-local bundle can be activated cleanly from an unbundled parent
158
- - a bundled parent process is not a reliable place to import a child carton with its own bundle
159
- - conflicting bundles in one process still need a subprocess workaround
160
-
161
- See [TODO.md](TODO.md) for the upstream Bundler work that would make this cleaner.
162
-
163
- ## Examples
164
-
165
- ```sh
166
- RUBY_BOX=1 ruby examples/minimal/main.rb
167
- RUBY_BOX=1 bundle exec rake example:complex
168
- ```
169
-
170
- See the example READMEs for details:
171
-
172
- - [examples/minimal/README.md](examples/minimal/README.md)
173
- - [examples/complex/README.md](examples/complex/README.md)
@@ -1,33 +0,0 @@
1
- # Complex example
2
-
3
- Multi-carton example showing the library directly: explicit load-path setup, a bundled single export loaded by name, a plain carton loaded by name, and a destructured import.
4
-
5
- ## Cartons
6
-
7
- | Carton | Purpose |
8
- | --- | --- |
9
- | `main` | explicit feature tour of the library |
10
- | `adventure` | bundled carton imported by name |
11
- | `quest` | plain carton imported by name |
12
- | `loot` | carton that demonstrates a conflicting `dotenv` version via subprocess |
13
-
14
- `main.rb` keeps the load-path setup and bundled import explicit on purpose so the example reads like a direct Carton feature tour. `Carton.with_bundle` shortens the `BUNDLE_GEMFILE` handoff, but it is still the same Bundler constraint under the hood.
15
-
16
- ## Why `loot` still uses a subprocess
17
-
18
- One carton-local bundle can be activated from an unbundled parent today. The trouble starts when another box tries to activate a conflicting version: box-local `$LOAD_PATH` is not enough because RubyGems activation state is still shared. `loot` therefore loads its `dotenv` data in a subprocess and selects its Gemfile there instead of using `Carton.with_bundle` during the import itself. That is tracked in [../../TODO.md](../../TODO.md).
19
-
20
- ## Run
21
-
22
- ```sh
23
- RUBY_BOX=1 bundle exec rake example:complex
24
- ```
25
-
26
- Or manually:
27
-
28
- ```sh
29
- cd examples/complex/cartons/adventure && BUNDLE_GEMFILE=Gemfile bundle install
30
- cd ../loot && BUNDLE_GEMFILE=Gemfile bundle install
31
- cd ../../..
32
- RUBY_BOX=1 ruby examples/complex/main.rb
33
- ```
@@ -1 +0,0 @@
1
- BANNER=single export loaded from a bundled carton
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- gem 'dotenv', '~> 3.0'
@@ -1,17 +0,0 @@
1
- GEM
2
- remote: https://rubygems.org/
3
- specs:
4
- dotenv (3.2.0)
5
-
6
- PLATFORMS
7
- arm64-darwin-25
8
- ruby
9
-
10
- DEPENDENCIES
11
- dotenv (~> 3.0)
12
-
13
- CHECKSUMS
14
- dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
15
-
16
- BUNDLED WITH
17
- 4.0.3
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'dotenv'
4
-
5
- module Adventure
6
- CONFIG = Dotenv.parse(File.expand_path('../.env', __dir__))
7
- def self.banner = CONFIG.fetch('BANNER')
8
-
9
- def self.dotenv_version = Gem.loaded_specs.fetch('dotenv').version.to_s
10
- end
11
-
12
- export_default Adventure
@@ -1 +0,0 @@
1
- MESSAGE=loaded through a conflicting bundle in a subprocess
@@ -1,5 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- source 'https://rubygems.org'
4
-
5
- gem 'dotenv', '~> 2.0'
@@ -1,17 +0,0 @@
1
- GEM
2
- remote: https://rubygems.org/
3
- specs:
4
- dotenv (2.8.1)
5
-
6
- PLATFORMS
7
- arm64-darwin-25
8
- ruby
9
-
10
- DEPENDENCIES
11
- dotenv (~> 2.0)
12
-
13
- CHECKSUMS
14
- dotenv (2.8.1) sha256=c5944793349ae03c432e1780a2ca929d60b88c7d14d52d630db0508c3a8a17d8
15
-
16
- BUNDLED WITH
17
- 4.0.3
@@ -1,47 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'json'
4
- require 'open3'
5
- require 'rbconfig'
6
-
7
- Plans = import 'quest'
8
-
9
- # This carton is imported plainly by name. The subprocess below is where it
10
- # selects its own Gemfile, because doing the conflicting dotenv activation
11
- # directly in another box still collides through shared RubyGems activation
12
- # state and can even crash under Ruby::Box today.
13
- dotenv_payload =
14
- begin
15
- env_file = File.expand_path('../.env', __dir__)
16
- gemfile = File.expand_path('../Gemfile', __dir__)
17
- script = <<~'RUBY'
18
- require 'json'
19
- require 'bundler/setup'
20
- require 'dotenv'
21
-
22
- config = Dotenv.parse(ARGV.fetch(0))
23
- version = Gem.loaded_specs.fetch('dotenv').version.to_s
24
- puts JSON.generate(version:, config:)
25
- RUBY
26
-
27
- output, status =
28
- Open3.capture2e(
29
- { 'BUNDLE_GEMFILE' => gemfile },
30
- RbConfig.ruby,
31
- '-e',
32
- script,
33
- env_file,
34
- )
35
-
36
- unless status.success?
37
- raise RuntimeError, "failed to load loot dotenv bundle: #{output}"
38
- end
39
-
40
- JSON.parse output
41
- end
42
-
43
- dotenv_version = dotenv_payload.fetch 'version'
44
- legacy_message =
45
- "#{dotenv_payload.fetch('config').fetch('MESSAGE')} | quest says: #{Plans.fetch :summary}"
46
-
47
- export legacy_message:, DOTENV_VERSION: dotenv_version
@@ -1,6 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- FEATURES = ['single exports', 'named exports', 'import by name'].freeze
4
- SUMMARY = 'plain carton loaded by name'
5
-
6
- export FEATURES:, summary: SUMMARY
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../lib/carton'
4
-
5
- cartons_dir = File.expand_path('cartons', __dir__)
6
-
7
- # Local carton lib directories still need to be on the caller's load path when
8
- # we want import 'name' to resolve inside nested boxes.
9
- Dir
10
- .glob(File.join(cartons_dir, '*/lib'))
11
- .sort
12
- .each { |dir| $LOAD_PATH.unshift(dir) unless $LOAD_PATH.include?(dir) }
13
-
14
- adventure_gemfile = File.expand_path('cartons/adventure/Gemfile', __dir__)
15
-
16
- # Bundler still picks the active Gemfile from env/process state today, so the
17
- # bundled import stays explicit even though the wrapper is small.
18
- Adventure = Carton.with_bundle(adventure_gemfile) { import 'adventure' }
19
-
20
- Plans = import 'quest'
21
-
22
- # loot still imports by name, but it does not need Carton.with_bundle here
23
- # because the conflicting dotenv activation happens entirely in the subprocess
24
- # inside loot.rb, not during this import itself.
25
- import('loot') => { legacy_message:, DOTENV_VERSION: loot_dotenv_version }
26
-
27
- puts '-- Bundled single export --'
28
- puts "Adventure.banner = #{Adventure.banner}"
29
- puts "Adventure.dotenv_version = #{Adventure.dotenv_version}"
30
- puts
31
- puts '-- Named export loaded by name --'
32
- puts "Plans::FEATURES = #{Plans::FEATURES.join(', ')}"
33
- puts "Plans.summary = #{Plans.fetch :summary}"
34
- puts
35
- puts '-- Destructured named export --'
36
- puts "loot dotenv version = #{loot_dotenv_version}"
37
- puts "legacy_message = #{legacy_message}"
@@ -1,16 +0,0 @@
1
- # Minimal example
2
-
3
- Smallest working example of the library.
4
-
5
- ## What it shows
6
-
7
- - single export (`foo.rb`, `baz.rb`)
8
- - named exports (`bar.rb`)
9
- - `import_relative`
10
- - one carton importing another carton internally
11
-
12
- ## Run
13
-
14
- ```sh
15
- RUBY_BOX=1 ruby examples/minimal/main.rb
16
- ```
@@ -1,10 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Bar depends on Baz via import_relative
4
- Baz = import_relative 'baz'
5
-
6
- def hello
7
- "Hello from Bar! (#{Baz.hello})"
8
- end
9
-
10
- export hello: method(:hello), MAGIC: 42
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Baz
4
- def self.hello = 'Hello from Baz!'
5
- end
6
-
7
- export_default Baz
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Foo
4
- def self.hello = 'Hello from Foo!'
5
- end
6
-
7
- export_default Foo
@@ -1,17 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative '../../lib/carton'
4
-
5
- # Single import — Foo exports a module
6
- Foo = import_relative 'foo'
7
-
8
- # Namespace import — Bar exports a hash with methods and constants
9
- Bar = import_relative 'bar'
10
-
11
- puts '-- Foo --'
12
- puts Foo.hello
13
-
14
- puts
15
- puts '-- Bar (uses Baz internally) --'
16
- puts Bar.hello
17
- puts "Bar::MAGIC = #{Bar::MAGIC}"
data/images/carton.png DELETED
Binary file
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Carton
4
- class Exports < ::Module
5
- include ExportMethods
6
-
7
- def initialize(values = {})
8
- super()
9
- @values = values
10
- @lookup = {}
11
- define_exports
12
- end
13
-
14
- private
15
-
16
- def define_exports
17
- @values.each do |key, value|
18
- @lookup[key.to_s] = value
19
-
20
- if key.to_s.match?(/\A[A-Z]/)
21
- const_set(key, value)
22
- else
23
- define_singleton_method(
24
- key,
25
- ) { |*args, **kwargs, &block| value.respond_to?(:call) ? value.call(*args, **kwargs, &block) : value }
26
- end
27
- end
28
- end
29
-
30
- def lookup_entry(key)
31
- name = key.to_s
32
- @lookup.key?(name) ? [true, @lookup[name]] : [false, nil]
33
- end
34
- end
35
- end