rubyx-py 0.2.0 → 0.2.1

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.
@@ -73,6 +73,7 @@ mod tests {
73
73
  use crate::stream::SendableValue;
74
74
  use crate::test_helpers::{skip_if_no_python, with_ruby_python};
75
75
  use crossbeam_channel::{bounded, unbounded};
76
+ use magnus::encoding::EncodingCapable;
76
77
  use magnus::value::ReprValue;
77
78
  use magnus::TryConvert;
78
79
  use serial_test::serial;
@@ -947,4 +948,171 @@ mod tests {
947
948
  assert_eq!(v, 99);
948
949
  });
949
950
  }
951
+
952
+ // ========== SendableValue::Bytes → Ruby String (ASCII-8BIT) ==========
953
+
954
+ #[test]
955
+ #[serial]
956
+ fn test_sendable_bytes_converts_to_ruby_string() {
957
+ with_ruby_python(|ruby, _api| {
958
+ let val: magnus::Value = SendableValue::Bytes(b"hello".to_vec())
959
+ .try_into()
960
+ .expect("Bytes conversion should succeed");
961
+
962
+ // Must be a Ruby String, not an Array of integers
963
+ let rstr = magnus::RString::try_convert(val).expect("should be a String");
964
+ let content = unsafe { rstr.as_slice() };
965
+ assert_eq!(content, b"hello");
966
+
967
+ // Verify encoding is ASCII-8BIT
968
+ let enc = rstr.enc_get();
969
+ let ascii_8bit = ruby
970
+ .find_encindex("ASCII-8BIT")
971
+ .expect("ASCII-8BIT must exist");
972
+ assert!(
973
+ enc == ascii_8bit,
974
+ "Bytes should produce ASCII-8BIT encoded String"
975
+ );
976
+ });
977
+ }
978
+
979
+ #[test]
980
+ #[serial]
981
+ fn test_sendable_bytes_empty() {
982
+ with_ruby_python(|ruby, _api| {
983
+ let val: magnus::Value = SendableValue::Bytes(Vec::new())
984
+ .try_into()
985
+ .expect("empty Bytes conversion should succeed");
986
+
987
+ let rstr = magnus::RString::try_convert(val).expect("should be a String");
988
+ let content = unsafe { rstr.as_slice() };
989
+ assert!(content.is_empty());
990
+
991
+ let enc = rstr.enc_get();
992
+ let ascii_8bit = ruby
993
+ .find_encindex("ASCII-8BIT")
994
+ .expect("ASCII-8BIT must exist");
995
+ assert!(enc == ascii_8bit, "encoding should be ASCII-8BIT");
996
+ });
997
+ }
998
+
999
+ #[test]
1000
+ #[serial]
1001
+ fn test_sendable_bytes_with_null_bytes() {
1002
+ with_ruby_python(|ruby, _api| {
1003
+ let data = vec![0x00, 0x01, 0xff, 0x00, 0xfe];
1004
+ let val: magnus::Value = SendableValue::Bytes(data.clone())
1005
+ .try_into()
1006
+ .expect("Bytes with NULs should convert");
1007
+
1008
+ let rstr = magnus::RString::try_convert(val).expect("should be a String");
1009
+ let content = unsafe { rstr.as_slice() };
1010
+ assert_eq!(content, data.as_slice());
1011
+
1012
+ let enc = rstr.enc_get();
1013
+ let ascii_8bit = ruby
1014
+ .find_encindex("ASCII-8BIT")
1015
+ .expect("ASCII-8BIT must exist");
1016
+ assert!(enc == ascii_8bit, "encoding should be ASCII-8BIT");
1017
+ });
1018
+ }
1019
+
1020
+ #[test]
1021
+ #[serial]
1022
+ fn test_sendable_bytes_all_256_values() {
1023
+ with_ruby_python(|ruby, _api| {
1024
+ let data: Vec<u8> = (0..=255).collect();
1025
+ let val: magnus::Value = SendableValue::Bytes(data.clone())
1026
+ .try_into()
1027
+ .expect("all byte values should convert");
1028
+
1029
+ let rstr = magnus::RString::try_convert(val).expect("should be a String");
1030
+ let content = unsafe { rstr.as_slice() };
1031
+ assert_eq!(content, data.as_slice());
1032
+
1033
+ let enc = rstr.enc_get();
1034
+ let ascii_8bit = ruby
1035
+ .find_encindex("ASCII-8BIT")
1036
+ .expect("ASCII-8BIT must exist");
1037
+ assert!(enc == ascii_8bit, "encoding should be ASCII-8BIT");
1038
+ });
1039
+ }
1040
+
1041
+ #[test]
1042
+ #[serial]
1043
+ fn test_sendable_bytes_is_not_array() {
1044
+ with_ruby_python(|_ruby, _api| {
1045
+ let val: magnus::Value = SendableValue::Bytes(b"test".to_vec())
1046
+ .try_into()
1047
+ .expect("Bytes conversion should succeed");
1048
+
1049
+ // Must NOT be an Array (Vec<u8>.into_value would produce Array<Integer>)
1050
+ assert!(
1051
+ magnus::RArray::try_convert(val).is_err(),
1052
+ "Bytes must not convert to Ruby Array"
1053
+ );
1054
+ });
1055
+ }
1056
+
1057
+ #[test]
1058
+ #[serial]
1059
+ fn test_sendable_bytes_in_stream_delivery() {
1060
+ with_ruby_python(|ruby, _api| {
1061
+ let (tx, rx) = unbounded();
1062
+ let (cancel_tx, _cancel_rx) = bounded(1);
1063
+
1064
+ thread::spawn(move || {
1065
+ tx.send(Some(SendableValue::Integer(1))).ok();
1066
+ tx.send(Some(SendableValue::Bytes(b"\xde\xad".to_vec())))
1067
+ .ok();
1068
+ tx.send(Some(SendableValue::Str("after".to_string()))).ok();
1069
+ tx.send(None).ok();
1070
+ });
1071
+
1072
+ let mut stream = crate::stream::AsyncStream::from_channel(rx, cancel_tx);
1073
+
1074
+ // First: integer
1075
+ let val = stream.next().unwrap().unwrap();
1076
+ assert_eq!(i64::try_convert(val).unwrap(), 1);
1077
+
1078
+ // Second: bytes → ASCII-8BIT String
1079
+ let val = stream.next().unwrap().unwrap();
1080
+ let rstr = magnus::RString::try_convert(val).expect("should be String");
1081
+ let content = unsafe { rstr.as_slice() };
1082
+ assert_eq!(content, b"\xde\xad");
1083
+ let enc = rstr.enc_get();
1084
+ let ascii_8bit = ruby
1085
+ .find_encindex("ASCII-8BIT")
1086
+ .expect("ASCII-8BIT must exist");
1087
+ assert!(enc == ascii_8bit, "encoding should be ASCII-8BIT");
1088
+
1089
+ // Third: regular string
1090
+ let val = stream.next().unwrap().unwrap();
1091
+ assert_eq!(String::try_convert(val).unwrap(), "after");
1092
+
1093
+ assert!(stream.next().is_none());
1094
+ });
1095
+ }
1096
+
1097
+ #[test]
1098
+ #[serial]
1099
+ fn test_sendable_bytes_large_payload() {
1100
+ with_ruby_python(|ruby, _api| {
1101
+ let data: Vec<u8> = (0..10_000).map(|i| (i % 256) as u8).collect();
1102
+ let val: magnus::Value = SendableValue::Bytes(data.clone())
1103
+ .try_into()
1104
+ .expect("large Bytes should convert");
1105
+
1106
+ let rstr = magnus::RString::try_convert(val).expect("should be a String");
1107
+ let content = unsafe { rstr.as_slice() };
1108
+ assert_eq!(content.len(), 10_000);
1109
+ assert_eq!(content, data.as_slice());
1110
+
1111
+ let enc = rstr.enc_get();
1112
+ let ascii_8bit = ruby
1113
+ .find_encindex("ASCII-8BIT")
1114
+ .expect("ASCII-8BIT must exist");
1115
+ assert!(enc == ascii_8bit, "encoding should be ASCII-8BIT");
1116
+ });
1117
+ }
950
1118
  }
@@ -1,8 +1,9 @@
1
- use crate::api;
2
1
  use crate::python_ffi::PyObject;
3
2
  use crate::ruby_helpers::runtime_error;
4
3
  use crate::rubyx_object::{python_to_sendable, RubyxObject};
4
+ use crate::{api, ruby_helpers};
5
5
  use crossbeam_channel::{bounded, unbounded, Receiver, Sender};
6
+ use magnus::encoding::EncodingCapable;
6
7
  use magnus::value::ReprValue;
7
8
  use magnus::{IntoValue, Value};
8
9
  use std::thread;
@@ -21,6 +22,8 @@ pub(crate) enum SendableValue {
21
22
  Float(f64),
22
23
  Str(String),
23
24
  Bool(bool),
25
+ Bytes(Vec<u8>),
26
+ Set(Vec<SendableValue>),
24
27
  List(Vec<SendableValue>),
25
28
  Dict(Vec<(SendableValue, SendableValue)>),
26
29
  PyObjectRef(usize),
@@ -38,7 +41,15 @@ impl TryInto<magnus::Value> for SendableValue {
38
41
  SendableValue::Float(f) => f.into_value_with(&ruby),
39
42
  SendableValue::Str(s) => s.as_str().into_value_with(&ruby),
40
43
  SendableValue::Bool(b) => b.into_value_with(&ruby),
41
- SendableValue::List(l) => {
44
+ SendableValue::Bytes(b) => {
45
+ let s = ruby.str_from_slice(&b);
46
+ s.enc_associate(ruby.find_encoding("ASCII-8BIT").ok_or(magnus::Error::new(
47
+ ruby_helpers::no_method_error(),
48
+ "No ASCII-8BIT",
49
+ ))?)?;
50
+ s.as_value()
51
+ }
52
+ SendableValue::List(l) | SendableValue::Set(l) => {
42
53
  let ruby_array = ruby.ary_new_capa(l.len());
43
54
  for item in l {
44
55
  let val: Value = item.try_into()?;
@@ -786,4 +797,139 @@ mod tests {
786
797
  api.decref(os);
787
798
  });
788
799
  }
800
+
801
+ // ========== Set: SendableValue::Set → Ruby Array ==========
802
+
803
+ #[test]
804
+ #[serial]
805
+ fn test_set_converts_to_ruby_array() {
806
+ with_ruby_python(|_ruby, _api| {
807
+ let (tx, rx) = unbounded();
808
+ let (cancel_tx, _cancel_rx) = bounded(1);
809
+
810
+ thread::spawn(move || {
811
+ tx.send(Some(SendableValue::Set(vec![
812
+ SendableValue::Integer(1),
813
+ SendableValue::Integer(2),
814
+ SendableValue::Integer(3),
815
+ ])))
816
+ .ok();
817
+ tx.send(None).ok();
818
+ });
819
+
820
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
821
+ let val = stream.next().unwrap().unwrap();
822
+
823
+ let arr = magnus::RArray::try_convert(val).unwrap();
824
+ assert_eq!(arr.len(), 3);
825
+
826
+ let mut items: Vec<i64> = (0..arr.len())
827
+ .map(|i| i64::try_convert(arr.entry::<Value>(i as isize).unwrap()).unwrap())
828
+ .collect();
829
+ items.sort();
830
+ assert_eq!(items, vec![1, 2, 3]);
831
+
832
+ assert!(stream.next().is_none());
833
+ });
834
+ }
835
+
836
+ #[test]
837
+ #[serial]
838
+ fn test_empty_set_converts_to_empty_ruby_array() {
839
+ with_ruby_python(|_ruby, _api| {
840
+ let (tx, rx) = unbounded();
841
+ let (cancel_tx, _cancel_rx) = bounded(1);
842
+
843
+ thread::spawn(move || {
844
+ tx.send(Some(SendableValue::Set(vec![]))).ok();
845
+ tx.send(None).ok();
846
+ });
847
+
848
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
849
+ let val = stream.next().unwrap().unwrap();
850
+
851
+ let arr = magnus::RArray::try_convert(val).unwrap();
852
+ assert_eq!(arr.len(), 0);
853
+
854
+ assert!(stream.next().is_none());
855
+ });
856
+ }
857
+
858
+ #[test]
859
+ #[serial]
860
+ fn test_set_with_mixed_types_converts_to_ruby_array() {
861
+ with_ruby_python(|_ruby, _api| {
862
+ let (tx, rx) = unbounded();
863
+ let (cancel_tx, _cancel_rx) = bounded(1);
864
+
865
+ thread::spawn(move || {
866
+ tx.send(Some(SendableValue::Set(vec![
867
+ SendableValue::Integer(42),
868
+ SendableValue::Str("hello".to_string()),
869
+ SendableValue::Float(3.14),
870
+ SendableValue::Bool(true),
871
+ ])))
872
+ .ok();
873
+ tx.send(None).ok();
874
+ });
875
+
876
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
877
+ let val = stream.next().unwrap().unwrap();
878
+
879
+ let arr = magnus::RArray::try_convert(val).unwrap();
880
+ assert_eq!(arr.len(), 4);
881
+
882
+ assert_eq!(
883
+ i64::try_convert(arr.entry::<Value>(0).unwrap()).unwrap(),
884
+ 42
885
+ );
886
+ assert_eq!(
887
+ String::try_convert(arr.entry::<Value>(1).unwrap()).unwrap(),
888
+ "hello"
889
+ );
890
+ assert!(
891
+ (f64::try_convert(arr.entry::<Value>(2).unwrap()).unwrap() - 3.14).abs() < 1e-9
892
+ );
893
+ assert!(bool::try_convert(arr.entry::<Value>(3).unwrap()).unwrap());
894
+
895
+ assert!(stream.next().is_none());
896
+ });
897
+ }
898
+
899
+ #[test]
900
+ #[serial]
901
+ fn test_set_in_stream_with_other_types() {
902
+ with_ruby_python(|_ruby, _api| {
903
+ let (tx, rx) = unbounded();
904
+ let (cancel_tx, _cancel_rx) = bounded(1);
905
+
906
+ thread::spawn(move || {
907
+ tx.send(Some(SendableValue::Integer(1))).ok();
908
+ tx.send(Some(SendableValue::Set(vec![
909
+ SendableValue::Integer(10),
910
+ SendableValue::Integer(20),
911
+ ])))
912
+ .ok();
913
+ tx.send(Some(SendableValue::Str("after".to_string()))).ok();
914
+ tx.send(None).ok();
915
+ });
916
+
917
+ let mut stream = AsyncStream::from_channel(rx, cancel_tx);
918
+
919
+ // First: integer
920
+ let val = stream.next().unwrap().unwrap();
921
+ assert_eq!(i64::try_convert(val).unwrap(), 1);
922
+
923
+ // Second: set → array
924
+ let val = stream.next().unwrap().unwrap();
925
+ let arr = magnus::RArray::try_convert(val).unwrap();
926
+ assert_eq!(arr.len(), 2);
927
+
928
+ // Third: string
929
+ let val = stream.next().unwrap().unwrap();
930
+ assert_eq!(String::try_convert(val).unwrap(), "after");
931
+
932
+ assert!(stream.next().is_none());
933
+ });
934
+ }
789
935
  }
data/lib/rubyx/railtie.rb CHANGED
@@ -14,7 +14,8 @@ module Rubyx
14
14
 
15
15
  # Register rake tasks
16
16
  rake_tasks do
17
- load 'rubyx/tasks/rubyx.rake'
17
+ task_file = File.expand_path('tasks/rubyx.rake', __dir__)
18
+ load task_file if File.exist?(task_file)
18
19
  end
19
20
  end
20
21
  end
@@ -0,0 +1,127 @@
1
+ namespace :rubyx do
2
+ desc 'Initialize Python environment (downloads uv and Python if needed)'
3
+ task init: :environment do
4
+ Rubyx::Rails.init!
5
+ puts '[Rubyx] Python environment initialized successfully.'
6
+ end
7
+
8
+ desc 'Check Python environment health'
9
+ task check: :environment do
10
+ puts 'Checking Python environment...'
11
+ puts
12
+
13
+ # Check uv
14
+ system_uv = `which uv 2>/dev/null`.strip
15
+ uv_available = !system_uv.empty? && File.exist?(system_uv)
16
+ puts "uv available: #{uv_available ? "Yes (#{system_uv})" : 'No (will auto-download)'}"
17
+
18
+ begin
19
+ Rubyx::Rails.ensure_initialized!
20
+ puts 'Python initialized: Yes'
21
+ rescue => e
22
+ puts "Python initialized: No (#{e.message})"
23
+ puts
24
+ puts 'Run `rake rubyx:init` to initialize.'
25
+ exit 1
26
+ end
27
+
28
+ begin
29
+ Rubyx.eval('1 + 1')
30
+ puts 'Basic eval: OK'
31
+ rescue => e
32
+ puts "Basic eval: FAILED (#{e.message})"
33
+ exit 1
34
+ end
35
+
36
+ begin
37
+ gen = Rubyx.eval("import sys\niter([sys.version.split()[0]])")
38
+ version = Rubyx.stream(gen).first
39
+ puts "Import sys: OK (Python #{version})"
40
+ rescue => e
41
+ puts "Import sys: FAILED (#{e.message})"
42
+ exit 1
43
+ end
44
+
45
+ puts
46
+ puts 'All checks passed!'
47
+ end
48
+
49
+ desc 'Show Rubyx configuration and status'
50
+ task status: :environment do
51
+ config = Rubyx::Rails.configuration
52
+
53
+ puts 'Rubyx Status'
54
+ puts '=' * 40
55
+
56
+ puts "Initialized: #{Rubyx::Rails.initialized?}"
57
+ puts
58
+
59
+ puts 'Configuration:'
60
+ puts " pyproject_path: #{config.pyproject_path || '(not set)'}"
61
+ puts " pyproject_content: #{config.pyproject_content ? '(inline, %d bytes)' % config.pyproject_content.length : '(not set)'}"
62
+ puts " auto_init: #{config.auto_init}"
63
+ puts " force_reinit: #{config.force_reinit}"
64
+ puts " uv_version: #{config.uv_version}"
65
+ puts " debug: #{config.debug}"
66
+ puts " python_paths: #{config.python_paths.inspect}"
67
+ puts " uv_path: #{config.uv_path || '(auto-download)'}"
68
+ puts " uv_args: #{config.uv_args.inspect}"
69
+ puts
70
+
71
+ if config.pyproject_path
72
+ exists = File.exist?(config.pyproject_path.to_s)
73
+ puts "pyproject.toml exists: #{exists}"
74
+ end
75
+
76
+ if config.pyproject_path
77
+ venv_dir = File.join(File.dirname(config.pyproject_path.to_s), '.venv')
78
+ puts ".venv exists: #{Dir.exist?(venv_dir)}"
79
+ end
80
+
81
+ system_uv = `which uv 2>/dev/null`.strip
82
+ puts "System uv: #{!system_uv.empty? ? system_uv : '(not found)'}"
83
+ end
84
+
85
+ desc 'List installed Python packages'
86
+ task packages: :environment do
87
+ Rubyx::Rails.ensure_initialized!
88
+
89
+ gen = Rubyx.eval(<<~PY)
90
+ import pkg_resources
91
+ packages = sorted([f"{d.project_name}=={d.version}" for d in pkg_resources.working_set])
92
+ iter(packages)
93
+ PY
94
+
95
+ puts 'Installed Python packages:'
96
+ Rubyx.stream(gen).each { |pkg| puts " #{pkg}" }
97
+ rescue => e
98
+ begin
99
+ gen = Rubyx.eval(<<~PY)
100
+ from importlib.metadata import distributions
101
+ packages = sorted([f"{d.metadata['Name']}=={d.metadata['Version']}" for d in distributions()])
102
+ iter(packages)
103
+ PY
104
+
105
+ puts 'Installed Python packages:'
106
+ Rubyx.stream(gen).each { |pkg| puts " #{pkg}" }
107
+ rescue => e2
108
+ puts "Could not list packages: #{e2.message}"
109
+ end
110
+ end
111
+
112
+ desc 'Clear the Rubyx cache (re-download uv + Python on next init)'
113
+ task clear_cache: :environment do
114
+ cache_dir = File.join(
115
+ ENV.fetch('XDG_CACHE_HOME', File.join(Dir.home, '.cache')),
116
+ 'rubyx'
117
+ )
118
+
119
+ if Dir.exist?(cache_dir)
120
+ require 'fileutils'
121
+ FileUtils.rm_rf(cache_dir)
122
+ puts "[Rubyx] Cache cleared: #{cache_dir}"
123
+ else
124
+ puts '[Rubyx] No cache directory found.'
125
+ end
126
+ end
127
+ end
data/lib/rubyx/version.rb CHANGED
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module Rubyx
3
- VERSION = "0.2.0".freeze
4
- end
3
+ VERSION = "0.2.1".freeze
4
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubyx-py
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Naiker
@@ -96,6 +96,7 @@ files:
96
96
  - lib/rubyx/error.rb
97
97
  - lib/rubyx/rails.rb
98
98
  - lib/rubyx/railtie.rb
99
+ - lib/rubyx/tasks/rubyx.rake
99
100
  - lib/rubyx/uv.rb
100
101
  - lib/rubyx/version.rb
101
102
  homepage: https://github.com/yinho999/rubyx