TeamBlast
TeamBlast

Reputation: 428

Pretty file size in Ruby?

I'm trying to make a method that converts an integer that represents bytes to a string with a 'prettied up' format.

Here's my half-working attempt:

class Integer
  def to_filesize
    {
      'B'  => 1024,
      'KB' => 1024 * 1024,
      'MB' => 1024 * 1024 * 1024,
      'GB' => 1024 * 1024 * 1024 * 1024,
      'TB' => 1024 * 1024 * 1024 * 1024 * 1024
    }.each_pair { |e, s| return "#{s / self}#{e}" if self < s }
  end
end

What am I doing wrong?

Upvotes: 26

Views: 26087

Answers (9)

noraj
noraj

Reputation: 4642

Unfortunately, filesize is abandoned since September 2018 and archived, while bytesize is unmaintained since June 2021.

An alternative is to use ActiveSupport which is:

A toolkit of support libraries and Ruby core extensions extracted from the Rails framework. Rich support for multibyte strings, internationalization, time zones, and testing.

It's meant to be used in Ruby on Rails but it's possible to use it ouside too.

Indeed, the NumberHelper class offers a ready to go number_to_human_size method that just does what we need:

Formats number as bytes into a more human-friendly representation. Useful for reporting file sizes to users.

To cherry-pick specific Active Support feature and have the minimal footprint, here is what you need to do:

# For Active Support 7+
require 'active_support' # skip this if Active Support 6-
require 'active_support/core_ext/numeric/conversions'

as = ActiveSupport::NumberHelper

as.number_to_human_size(1_000_000_000) # => "954 MB"
as.number_to_human_size(23_897)        # => "23.3 KB"
as.number_to_human_size(1024)          # => "1 KB"
as.number_to_human_size(64)            # => "64 Bytes"

as.number_to_human_size(27_198_870_567) # => "25.3 GB"
as.number_to_human_size(27_198_870_567, precision: 5) # => "25.331 GB"
as.number_to_human_size(27_198_870_567, precision: 2, round_mode: :up) # => "26 GB"
as.number_to_human_size(27_198_870_567_000_000_000_000_000, separator: ',', delimiter: ' ') # => "23 000 ZB"

Active Support loads the minimum dependencies by default thanks to an autoload mechanism, that's why you need this double requirement.

Upvotes: 1

Mike Slinn
Mike Slinn

Reputation: 8407

FileSize may be dead, but now there is ByteSize.

require 'bytesize'

ByteSize.new(1210000000)       #=> (1.21 GB)
ByteSize.new(1210000000).to_s  #=> 1.21 GB

Upvotes: 1

Zombo
Zombo

Reputation: 1

Here is a method using log10:

def number_format(d)
   e = Math.log10(d).to_i / 3
   return '%.3f' % (d / 1000 ** e) + ['', ' k', ' M', ' G'][e]
end

s = number_format(9012345678.0)
puts s == '9.012 G'

https://ruby-doc.org/core/Math.html#method-c-log10

Upvotes: 4

FelipeC
FelipeC

Reputation: 9488

This is my solution:

def filesize(size)
  units = %w[B KiB MiB GiB TiB Pib EiB ZiB]

  return '0.0 B' if size == 0
  exp = (Math.log(size) / Math.log(1024)).to_i
  exp += 1 if (size.to_f / 1024 ** exp >= 1024 - 0.05)
  exp = units.size - 1 if exp > units.size - 1

  '%.1f %s' % [size.to_f / 1024 ** exp, units[exp]]
end

Compared to other solutions it's simpler, more efficient, and generates a more proper output.

Format

All other methods have the problem that they report 1023.95 bytes wrong. Moreover to_filesize simply errors out with big numbers (it returns an array).

 -       method: [     filesize,     Filesize,  number_to_human,  to_filesize ]
 -          0 B: [        0.0 B,       0.00 B,          0 Bytes,         0.0B ]
 -          1 B: [        1.0 B,       1.00 B,           1 Byte,         1.0B ]
 -         10 B: [       10.0 B,      10.00 B,         10 Bytes,        10.0B ]
 -       1000 B: [     1000.0 B,    1000.00 B,       1000 Bytes,      1000.0B ]
 -        1 KiB: [      1.0 KiB,     1.00 KiB,             1 KB,        1.0KB ]
 -      1.5 KiB: [      1.5 KiB,     1.50 KiB,           1.5 KB,        1.5KB ]
 -       10 KiB: [     10.0 KiB,    10.00 KiB,            10 KB,       10.0KB ]
 -     1000 KiB: [   1000.0 KiB,  1000.00 KiB,          1000 KB,     1000.0KB ]
 -        1 MiB: [      1.0 MiB,     1.00 MiB,             1 MB,        1.0MB ]
 -        1 GiB: [      1.0 GiB,     1.00 GiB,             1 GB,        1.0GB ]
 -  1023.95 GiB: [      1.0 TiB,  1023.95 GiB,          1020 GB,    1023.95GB ]
 -        1 TiB: [      1.0 TiB,     1.00 TiB,             1 TB,        1.0TB ]
 -        1 EiB: [      1.0 EiB,     1.00 EiB,             1 EB,        ERROR ]
 -        1 ZiB: [      1.0 ZiB,     1.00 ZiB,          1020 EB,        ERROR ]
 -        1 YiB: [   1024.0 ZiB,  1024.00 ZiB,       1050000 EB,        ERROR ]

Performance

Also, it has the best performance (seconds to process 1 million numbers):

 - filesize:           2.15
 - Filesize:          15.53
 - number_to_human:  139.63
 - to_filesize:        2.41

Upvotes: 12

ChuckCottrill
ChuckCottrill

Reputation: 4444

You get points for adding a method to Integer, but this seems more File specific, so I would suggest monkeying around with File, say by adding a method to File called .prettysize().

But here is an alternative solution that uses iteration, and avoids printing single bytes as float :-)

def format_mb(size)
  conv = [ 'b', 'kb', 'mb', 'gb', 'tb', 'pb', 'eb' ];
  scale = 1024;

  ndx=1
  if( size < 2*(scale**ndx)  ) then
    return "#{(size)} #{conv[ndx-1]}"
  end
  size=size.to_f
  [2,3,4,5,6,7].each do |ndx|
    if( size < 2*(scale**ndx)  ) then
      return "#{'%.3f' % (size/(scale**(ndx-1)))} #{conv[ndx-1]}"
    end
  end
  ndx=7
  return "#{'%.3f' % (size/(scale**(ndx-1)))} #{conv[ndx-1]}"
end

Upvotes: 1

David P
David P

Reputation: 3634

How about the Filesize gem ? It seems to be able to convert from bytes (and other formats) into pretty printed values:

example:

Filesize.from("12502343 B").pretty      # => "11.92 MiB"

http://rubygems.org/gems/filesize

Upvotes: 26

Wile E.
Wile E.

Reputation: 1223

If you use it with Rails - what about standard Rails number helper?

http://api.rubyonrails.org/classes/ActionView/Helpers/NumberHelper.html#method-i-number_to_human_size

number_to_human_size(number, options = {})

?

Upvotes: 38

Steeve McCauley
Steeve McCauley

Reputation: 703

@Darshan Computing's solution is only partial here. Since the hash keys are not guaranteed to be ordered this approach will not work reliably. You could fix this by doing something like this inside the to_filesize method,

 conv={
      1024=>'B',
      1024*1024=>'KB',
      ...
 }
 conv.keys.sort.each { |s|
     next if self >= s
     e=conv[s]
     return "#{(self.to_f / (s / 1024)).round(2)}#{e}" if self < s }
 }

This is what I ended up doing for a similar method inside Float,

 class Float
   def to_human
     conv={
       1024=>'B',
       1024*1024=>'KB',
       1024*1024*1024=>'MB',
       1024*1024*1024*1024=>'GB',
       1024*1024*1024*1024*1024=>'TB',
       1024*1024*1024*1024*1024*1024=>'PB',
       1024*1024*1024*1024*1024*1024*1024=>'EB'
     }
     conv.keys.sort.each { |mult|
        next if self >= mult
        suffix=conv[mult]
        return "%.2f %s" % [ self / (mult / 1024), suffix ]
     }
   end
 end

Upvotes: 0

Darshan Rivka Whittle
Darshan Rivka Whittle

Reputation: 34041

I agree with @David that it's probably best to use an existing solution, but to answer your question about what you're doing wrong:

  1. The primary error is dividing s by self rather than the other way around.
  2. You really want to divide by the previous s, so divide s by 1024.
  3. Doing integer arithmetic will give you confusing results, so convert to float.
  4. Perhaps round the answer.

So:

class Integer
  def to_filesize
    {
      'B'  => 1024,
      'KB' => 1024 * 1024,
      'MB' => 1024 * 1024 * 1024,
      'GB' => 1024 * 1024 * 1024 * 1024,
      'TB' => 1024 * 1024 * 1024 * 1024 * 1024
    }.each_pair { |e, s| return "#{(self.to_f / (s / 1024)).round(2)}#{e}" if self < s }
  end
end

lets you:

1.to_filesize
# => "1.0B"
1020.to_filesize
# => "1020.0B" 
1024.to_filesize
# => "1.0KB" 
1048576.to_filesize
# => "1.0MB"

Again, I don't recommend actually doing that, but it seems worth correcting the bugs.

Upvotes: 17

Related Questions