DEV Community

loading...
Cover image for Hairless Furever: My First CLI Gem

Hairless Furever: My First CLI Gem

Dorthy Thielsen
Full Stack Web Dev student at Flatiron
・13 min read

I have finally made it to my final project in Ruby. The task is to create a CLI gem which you have to create from scratch. I was terrified at the prospect of having to create something from nothing. I am so used to having test suites and all of the files created for me. When I was reading the directions it said we have to have a license, and my first thought was, “How do you go about licensing? What if I can’t figure out one of the first steps?” However I learned it isn’t so scary to create from scratch as long as you use the bundle gem. This magical tool creates all of the files I am used to having handed to me. Already my README, license, rakefile, etc. pop up like magic.

While bundle gem was a cool thing to learn about, it wasn’t my first step. I first planned out my app and made sure the website I wanted to use was scrapable. I decided to make an app all about hairless dogs. I have been obsessed with hairless dogs almost my whole life. Information about them isn’t readily available and is rarely in one place. When I was searching for hairless breeds, only two websites had all of the breeds listed. Even the Wikipedia article and the AKC had two to four breeds listed. I wanted to create a simple gem that I was passionate about. Also the dog in the cover image is my puppy named Momo who is a Chinese Crested. I used a scraper checker replit to make sure the information I wanted to gather was indeed scrapeable. I then followed the advice to create a flowchart and write about how this app will work. This also told me how many classes I would need. These classes would be CLI, Dog, and Scraper. I decided I wanted people to choose from a list of hairless dogs and then show them more information about each breed including description, height, weight, and physical characteristics. I was hoping to gather some more information, but not all of the breeds had a lot more information.
Flowchart of how Hairless Furever the CLI gem will work

Now that all of the planning was done, it was time to finally run bundle gem hairless_furever. “Hairless Furever” is what I decided to name the app. This would allow for expansion of other types of hairless critters like cats, guinea pigs, etc.

I wanted to set up my git and repo so everything would be stored. I learned that the gitignore file doesn’t push the files listed into the repo. I created a repo on GitHub and then connected all my files to the magical cloud. Then I created and changed the permissions on my ./bin/hairless_furever so that the file would be executable by chmod +x hairless_furever. This file also needed the shbang #!/usr/bin/env ruby to be added so the program would know what language to use. I also added a puts statement so I could tell when things were running properly. I made sure that the class CLI had a namespace name so it would be easy to distinguish when someone is using the app and being able to differentiate from other apps. For example, there are probably tons of already existing gems with a class of CLI, Dog, and Scraper. I needed to make sure that my gem could easily interact with other gems with similar class names.

The next task at hand was to make sure I had access to other gems. Since I need to understand what my code is doing, pry is a must. Nokogiri and open uri are essential to the scraping I will need to do. Then the classic bundler and rake also needed to be available. I popped all of that into my gemspec file and after a bit of trial and error, everything was working. I had an executable file that was able to run a class that I made, and my scraping and debugging tool.

I wanted to start stubbing out my CLI so I can have the base of the user interaction figured out before I started scraping anything. Stubbing is when you put in fake data just to see how things were running. For instance instead of trying to scrape a website to get the list of dogs, I just hardcoded in an array of the dog breeds. I started with just commenting in the steps of a welcome message, get the list of breeds, get the user input, show them a dog, and allow them to leave the program. I also realized I needed to make sure that the input would be valid and that the program would loop. All the information I was using to create this was not the real data but I just wanted the bare bones to build off of so I could better test and troubleshoot as I went.

I created the Dog class with the general initialize, @@all array, save, self.all methods. At this point I only added the attr_accessor of name, as I wasn’t quite sure what else I would need at this point, and I wasn’t going to start scraping for other content until I got the breed list figured out. Note: I did eventually change my attr_accessor to a attr_reader as I didn't need my Dog class to write any of this data as my scraper class would be doing the writing.

Now was the time to start scraping. I created my DogCatcher class and used the information I had gathered earlier from the replit of the scraper checker. I ran into a silly mistake of calling my Dog.new outside of my each loop and I kept getting a return of an instance of the class object instead of my list when I ran my program. After doing a little digging, I moved this inside the loop and everything was running again. I started creating other attributes for each dog like description, weight, height, and physical characteristics. I wanted every dog object instance to have all of these attributes assigned to it. However, I ran into more issues with my scraping method. First issue was that everything I was scraping was being stored into one instance. All ten dog breeds, and all of their attributes were in one instance of the dog class. The next issue was that I had nested paragraphs that had no unique identifiers that I wanted to get information from and I couldn’t figure out how to separate the data. I tried calling the children method, but that kept getting a no method error and the index was returning just one piece of information, not looping. At this point, I was feeling really defeated. I felt like I completely broke my program. Something that was working the way I wanted to, was now either not running, not returning information, or not returning the correct information. In the meantime, I decided to add an ASCII dog to the end of my program to lift my spirits. I wasn’t able to find the author of the ASCII unfortunately. I also ran into an issue with my \ just not showing up. After some googling, I found out that if I put two in a row, only one would show up and solve the problem. Not sure if that is the best solution but it worked. With my methods seeming broken, I commented almost everything out and decided to try a new approach of storing my information into an array. After trying that, I ran into the same problems, so there is still something wrong with my scraping method and not with what I originally wrote. I went back to my original code because it is more object oriented, the whole point of this project and it was also easier to read. I also found someone elses project that used a similar method to what I was doing, and theirs worked, so it gave me hope to continue.

I decided to watch some tutorials and I at least solved one of the issues I was having. The issue was with the nested paragraphs with no unique identifiers that I needed to scrape information from. I had previously tried children and calling an index on it, but neither one of those worked. In one of the videos I was watching, I saw them add :first into the css selector itself! I was previously calling it outside of the selector. For example I was first trying height = breed.css(".comp.mntl-sc-block-callout-body p").first.text instead of height = breed.css(".comp.mntl-sc-block-callout-body p:first").text. I used this also to do :last and the one that took awhile to find :nth-child(2). Here is a great guide to the selectors. Finally I had those tricky selectors figured out. Speaking of selectors, I got help from my section lead on why all of my information was getting shoveled into one instance of the Dog class. I showed him my screen recording of when I originally had it working, and he noticed that I forgot to add li to my selector. Basically what was happening was that since my selector up one level from where it needed to be, it was treating the whole section as one, instead of seeing each li element. I knew it was going to be something silly that I was overlooking. We added that to my code, and ta da! Everything was working. He also looked at my code and said it was good enough to turn in! I was so relieved.

As difficult as this project was, I learned a lot. It also wasn’t as scary as I originally thought. It was fascinating to learn how easy it was to create a gem with bundler. It was great to challenge myself with figuring out the css selectors. I learned how to select with children which was something I read about but never put into practice before. These selectors I also clearly misunderstood before. This is why doing is always so important. It is one thing to read it but another to actually implement it. Since I had For anyone else who is doing this project, I do want to say be patient, watch all of the videos, and don’t be hard on yourself. I would also say to keep it simple. As someone with no previous coding experience, I did something very simple. The great thing about this is that you can expand it. I started just with stubbing everything out, then just getting the name to work and then adding on from there. If I would have put too much in at once, it would have been harder to troubleshoot and debug. I also tried to keep every method simple. No method should be trying to do too much. I noticed with a lot of the refactoring videos I watched, most of them were separating things into smaller methods and also not repeating yourself too much. Here a video of me walking through my code.

Here is an explanation of how my code works. The CLI class starts with the call method, which calls on the scraper class, named DogCatcher. The DogCatcher class scrapes my website for name, description, height, weight, and physical characteristics using Nokogiri and open uri. Each instance is saved to my Dog class using the find_or_create_by_name method which takes in an argument of all of the attributes mentioned. With the scraping done, the program goes back to the CLI class. Here the CLI class welcomes the user. Next the program has a flow loop that until the user input is "exit" the program will continue to run in a loop of fetched_dogs, and fetch_user_dog which have other methods that they call on that also add to the looping ability of my program. If the user chooses exit, then the farewell method runs and the program ends.

class HairlessFurever::CLI

    def call
        #start scraping method
        HairlessFurever::DogCatcher.catch_dog_breeds
        #welcome user and set flow of program
        puts "\nWelcome to Hairless Furever!\n"
        puts "\nHere you can learn all about hairless dog breeds.\n"
        puts "U・ᴥ・U"
        puts " u u"
        @input = ""
        until @input == "exit"
            fetched_dogs
            fetch_user_dog
        end
        farewell
    end
# we will get back to the rest of the CLI in a bit

class HairlessFurever::DogCatcher

    def self.catch_dog_breeds
        doc = Nokogiri::HTML(open("https://www.thesprucepets.com/hairless-dog-breeds-4801015"))
        doc.css("ul#sc-list_1-0 li").each do |breed| 

            name = breed.css(".mntl-sc-block-heading").text.strip
            description = breed.css("p.comp.text-passage").text
            height = breed.css(".comp.mntl-sc-block-callout-body p:first").text
            weight = breed.css(".comp.mntl-sc-block-callout-body p:nth-child(2)").text 
            physical_characteristics = breed.css(".comp.mntl-sc-block-callout-body p:last").text

            HairlessFurever::Dog.find_or_create_by_name(name, description, height, weight, physical_characteristics)

        end
    end
end
Enter fullscreen mode Exit fullscreen mode

As mentioned the first thing in the loop of the CLI is fetched_dogs. This method calls on the Dog class and grabs all the dogs and stores each into an instance variable. It then asks the user which dog they would like information on. The instance variable of dogs is then called on. I used .each.with_index(1) as opposed to each_with_index. When you use .each.with_index(argument) it takes in an argument and you can tell it to start the indexing at 1 instead of the default 0 that each_with_index would do. This starts a loop that puts out each dog name with its respective index.

Since I mentioned Dog.all let's jump into my Dog class. As previously mentioned, the Dog class has all attr_reader of the variables, since the DogCatcher class is doing the writing. Next is the class variable @@all which is set to an empty array. Why I am using a class variable is so it is accessible across all of the methods in my Dog class. I of course have the classic initialize method which reads all of my attr_reader variables. I have a save method instead of doing the shoveling into the array in the initialize method since we don't always want to save upon initialize. I also have a better method in place. Next is the class method of self.all which reads the @@all array which is what we are using to generate our list in the CLI fetched_dogs method. I think I have mentioned this previously in another post, but something I learned was to keep methods short and concise. You don't want one method doing too much. Having methods that do a single thing is more flexible and allows more room to build out your app. First up in that realm is the class method of self.create this method not only initializes an instance of the Dog class taking in all of the attributes of the dog, it also saves these into the @@all array by calling on the save method. Next is the find_by_name(name) method. This method makes sure that I don't have duplicate entries in my @@all array by using detect. Finally the previously mentioned find_or_create_by_name method. This method was called upon in the DogCatcher method to create instances of my Dog class. The great thing is that I call upon both my find_by_name and create methods. If the dog name is not found by find_by_name then that dog is created using the create method.

class HairlessFurever::CLI
    def fetched_dogs
        #getting all the dog breeds
        @dogs = HairlessFurever::Dog.all
        puts "\nPlease enter the number of the dog you would like more information on.\n" 
        @dogs.each.with_index(1) {|dog, index| puts "#{index}. #{dog.name}"}
    end

class HairlessFurever::Dog
    attr_reader :name, :description, :height, :weight, :physical_characteristics

    @@all = []

    def initialize(name, description, height, weight, physical_characteristics)
        @name = name
        @description = description
        @height = height
        @weight = weight
        @physical_characteristics = physical_characteristics
    end

    def save
        @@all << self
    end

    def self.all
        #HairlessFurever::DogCatcher.catch_dog_breeds if @@all.empty?
        @@all 
    end

    def self.create(name, description, height, weight, physical_characteristics)
        dog = self.new(name, description, height, weight, physical_characteristics)
        dog.save
        dog
    end

    def self.find_by_name(name)
        self.all.detect {|dog| dog.name == name}
    end

    def self.find_or_create_by_name(name, description, height, weight, physical_characteristics)
        if dog = self.find_by_name(name)
            dog
        else
            self.create(name, description, height, weight, physical_characteristics)
        end
    end
end
Enter fullscreen mode Exit fullscreen mode

All that is left is the rest of my CLI class. The fetch_user_dog takes in the input of the user, turns in into an integer as we want this do find the chosen_dog by the index assigned to it in the fetched_dog method. This method than calls on the show_dog(chosen_dog) method if the input is valid. The input is valid if it is greater than 1 and not more than the length of the array that all the dogs are housed in. It would have been easy here to just put if the input is between 1-10 then it is valid, but that isn't flexible. What if I wanted to expand this program? I would have to re-write this code. It isn't a huge deal at this point, but it is better to allow for the possibility of expansion. If the input isn't valid, then the program returns to fetched_dogs and asks for input again. If the input is valid then it goes to the show_dog(chosen_dog) method and puts the dog's name and description. It then asks if the user would like more information. If the input is "y" then it goes to the details(chosen_dog) method where more information is put out to the user on the same dog and then it calls on the method of continue. If the user enters anything other than "y" than the program also calls on the 'continuemethod. In thecontinuemethod the user is asked if they would like to see the list again, or if they want to exit. If they type "exit" then the program calls on thefarewellmethod. Remember that in thecallmethod, until the user input is "exit" continue to the loop the program. If the user enters anything other than "exit" than the program returns tofetched_dogand the user is shown the list of dogs again. Thefarewell` method just puts out an ASCII dog and thanks the user. That is my program in a nutshell. It is very simple, but I am really proud of it.

`ruby
class HairlessFurever::CLI
def fetch_user_dog
chosen_dog = gets.strip.to_i
#getting and checking user input to make sure it is valid
show_dog(chosen_dog) if valid_input(chosen_dog, @dogs)
end

def valid_input(user_input, data)
    #input needs to be less than the amount of data(dog breeds) but more than 0 and can't be a negative number 
    user_input.to_i.between?(1, data.length)
    #user_input.to_i <= data.length && user_input.to_i > 0 also works

end

def show_dog(chosen_dog)
    #show dog name and description 
    dog = @dogs[chosen_dog -1]
    puts "#{dog.name}: #{dog.description}"
    puts "\nWould you like to get more information on this dog? (y/n)\n"
    @input = gets.strip
    if @input == "y"
        details(chosen_dog)
    else
        continue
    end
end

def details(chosen_dog)
    #show more details about the selected dog breed
    dog = @dogs[chosen_dog -1]
    puts "#{dog.height}"
    puts "#{dog.weight}"
    puts "#{dog.physical_characteristics}"
    continue
end

def continue
    #see if user wants to continue or exit
    puts "\nPress any key to see the list of dogs again. Type 'exit' to leave.\n"
    @input = gets.strip
end

def farewell
    puts ""
    puts "             /)-_-(\\"       
    puts "              (o o)"          
    puts "      .-----__/\\o/"           
    puts "     /  __      / "             
    puts " \\__/\\ /  \\_\\ |/ "                
    puts "      \\\\     || "                 
    puts "      //     || "                 
    puts "      |\\     |\\ "                 
    puts "\nThanks for stopping by.\n"
    puts ""
end
Enter fullscreen mode Exit fullscreen mode

end
`

Discussion (0)