docs.thor 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. class DocsCLI < Thor
  2. include Thor::Actions
  3. def self.to_s
  4. 'Docs'
  5. end
  6. def initialize(*args)
  7. require 'docs'
  8. trap('INT') { puts; exit! } # hide backtrace on ^C
  9. super
  10. end
  11. desc 'list', 'List available documentations'
  12. option :packaged, type: :boolean
  13. def list
  14. if options[:packaged]
  15. names = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') }
  16. puts names
  17. else
  18. names = Docs.all.flat_map do |doc|
  19. name = doc.to_s.demodulize.underscore
  20. if doc.versioned?
  21. doc.versions.map { |_doc| "#{name}@#{_doc.version}" }
  22. else
  23. name
  24. end
  25. end
  26. output = names.join("\n")
  27. require 'tty-pager'
  28. TTY::Pager.new.page(output)
  29. end
  30. end
  31. desc 'page <doc> [path] [--version] [--verbose] [--debug]', 'Generate a page (no indexing)'
  32. option :version, type: :string
  33. option :verbose, type: :boolean
  34. option :debug, type: :boolean
  35. def page(name, path = '')
  36. unless path.empty? || path.start_with?('/')
  37. return puts 'ERROR: [path] must be an absolute path.'
  38. end
  39. Docs.install_report :image
  40. Docs.install_report :store if options[:verbose]
  41. if options[:debug]
  42. GC.disable
  43. Docs.install_report :filter, :request
  44. end
  45. if Docs.generate_page(name, options[:version], path)
  46. puts 'Done'
  47. else
  48. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  49. end
  50. rescue Docs::DocNotFound => error
  51. handle_doc_not_found_error(error)
  52. end
  53. desc 'generate <doc> [--version] [--verbose] [--debug] [--force] [--package]', 'Generate a documentation'
  54. option :version, type: :string
  55. option :all, type: :boolean
  56. option :verbose, type: :boolean
  57. option :debug, type: :boolean
  58. option :force, type: :boolean
  59. option :package, type: :boolean
  60. def generate(name)
  61. Docs.rescue_errors = true
  62. Docs.install_report :store if options[:verbose]
  63. Docs.install_report :scraper if options[:debug]
  64. Docs.install_report :progress_bar, :doc, :image, :requester if $stdout.tty?
  65. require 'unix_utils' if options[:package]
  66. doc = Docs.find(name, options[:version])
  67. if doc < Docs::UrlScraper && !options[:force]
  68. puts <<-TEXT.strip_heredoc
  69. /!\\ WARNING /!\\
  70. Some scrapers send thousands of HTTP requests in a short period of time,
  71. which can slow down the source site and trouble its maintainers.
  72. Please scrape responsibly. Don't do it unless you're modifying the code.
  73. To download the latest tested version of this documentation, run:
  74. thor docs:download #{name}\n
  75. TEXT
  76. return unless yes? 'Proceed? (y/n)'
  77. end
  78. result = if doc.version && options[:all]
  79. doc.superclass.versions.all? do |_doc|
  80. puts "==> #{_doc.version}"
  81. generate_doc(_doc, package: options[:package]).tap { puts "\n" }
  82. end
  83. else
  84. generate_doc(doc, package: options[:package])
  85. end
  86. generate_manifest if result
  87. rescue Docs::DocNotFound => error
  88. handle_doc_not_found_error(error)
  89. ensure
  90. Docs.rescue_errors = false
  91. end
  92. desc 'manifest', 'Create the manifest'
  93. def manifest
  94. generate_manifest
  95. puts 'Done'
  96. end
  97. desc 'download (<doc> <doc@version>... | --default | --installed)', 'Download documentations'
  98. option :default, type: :boolean
  99. option :installed, type: :boolean
  100. option :all, type: :boolean
  101. def download(*names)
  102. require 'unix_utils'
  103. docs = if options[:default]
  104. Docs.defaults
  105. elsif options[:installed]
  106. Docs.installed
  107. elsif options[:all]
  108. Docs.all_versions
  109. else
  110. find_docs(names)
  111. end
  112. assert_docs(docs)
  113. download_docs(docs)
  114. generate_manifest
  115. puts 'Done'
  116. rescue Docs::DocNotFound => error
  117. handle_doc_not_found_error(error)
  118. end
  119. desc 'package <doc> <doc@version>...', 'Package documentations'
  120. def package(*names)
  121. require 'unix_utils'
  122. docs = find_docs(names)
  123. assert_docs(docs)
  124. docs.each(&method(:package_doc))
  125. puts 'Done'
  126. rescue Docs::DocNotFound => error
  127. handle_doc_not_found_error(error)
  128. end
  129. desc 'clean', 'Delete documentation packages'
  130. def clean
  131. File.delete(*Dir[File.join Docs.store_path, '*.tar.gz'])
  132. puts 'Done'
  133. end
  134. desc 'upload', '[private]'
  135. option :dryrun, type: :boolean
  136. option :packaged, type: :boolean
  137. def upload(*names)
  138. require 'net/sftp'
  139. names = Dir[File.join(Docs.store_path, '*.tar.gz')].map { |f| File.basename(f, '.tar.gz') } if options[:packaged]
  140. docs = find_docs(names)
  141. assert_docs(docs)
  142. # Sync files with S3 (used by the web app)
  143. puts '[S3] Begin syncing.'
  144. docs.each do |doc|
  145. puts "[S3] Syncing #{doc.path}..."
  146. cmd = "aws s3 sync #{File.join(Docs.store_path, doc.path)} s3://docs.devdocs.io/#{doc.path} --delete"
  147. cmd << ' --dryrun' if options[:dryrun]
  148. system(cmd)
  149. end
  150. puts '[S3] Done syncing.'
  151. # Upload packages to dl.devdocs.io (used by the "thor docs:download" command)
  152. puts '[MaxCDN] Begin uploading.'
  153. Net::SFTP.start('ftp.devdocs-dl.devdocs.netdna-cdn.com', ENV['DEVDOCS_DL_USERNAME'], password: ENV['DEVDOCS_DL_PASSWORD']) do |sftp|
  154. docs.each do |doc|
  155. filename = "#{doc.path}.tar.gz"
  156. print "[MaxCDN] Uploading #{filename}..."
  157. if options[:dryrun]
  158. print "\n"
  159. else
  160. sftp.upload! File.join(Docs.store_path, filename), File.join('', 'public_html', filename)
  161. print " OK\n"
  162. end
  163. end
  164. end
  165. puts '[MaxCDN] Done uploading.'
  166. end
  167. desc 'commit', '[private]'
  168. option :message, type: :string
  169. option :amend, type: :boolean
  170. def commit(name)
  171. doc = Docs.find(name, false)
  172. message = options[:message] || "Update #{doc.name} documentation (#{doc.versions.first.release})"
  173. amend = " --amend" if options[:amend]
  174. system("git add assets/ *#{name}*") && system("git commit -m '#{message}'#{amend}")
  175. rescue Docs::DocNotFound => error
  176. handle_doc_not_found_error(error)
  177. end
  178. desc 'prepare_deploy', 'Internal task executed before deployment'
  179. def prepare_deploy
  180. puts 'Docs -- BEGIN'
  181. require 'open-uri'
  182. require 'thread'
  183. docs = Docs.all_versions
  184. time = Time.now.to_i
  185. mutex = Mutex.new
  186. (1..6).map do
  187. Thread.new do
  188. while doc = docs.shift
  189. dir = File.join(Docs.store_path, doc.path)
  190. FileUtils.mkpath(dir)
  191. ['index.json', 'meta.json'].each do |filename|
  192. open("https://docs.devdocs.io/#{doc.path}/#{filename}?#{time}") do |file|
  193. mutex.synchronize do
  194. path = File.join(dir, filename)
  195. File.write(path, file.read)
  196. end
  197. end
  198. end
  199. puts "Docs -- Downloaded #{doc.slug}"
  200. end
  201. end
  202. end.map(&:join)
  203. puts 'Docs -- Generating manifest...'
  204. generate_manifest
  205. puts 'Docs -- DONE'
  206. end
  207. private
  208. def find_docs(names)
  209. names.flat_map do |name|
  210. name, version = name.split(/@|~/)
  211. if version == 'all'
  212. Docs.find(name, false).versions
  213. else
  214. Docs.find(name, version)
  215. end
  216. end
  217. end
  218. def assert_docs(docs)
  219. if docs.empty?
  220. puts 'ERROR: called with no arguments.'
  221. puts 'Run "thor list" for usage patterns.'
  222. exit
  223. end
  224. end
  225. def handle_doc_not_found_error(error)
  226. puts %(ERROR: #{error}.)
  227. puts 'Run "thor docs:list" to see the list of docs and versions.'
  228. end
  229. def generate_doc(doc, package: nil)
  230. if Docs.generate(doc)
  231. package_doc(doc) if package
  232. puts 'Done'
  233. true
  234. else
  235. puts "Failed!#{' (try running with --debug for more information)' unless options[:debug]}"
  236. false
  237. end
  238. end
  239. def download_docs(docs)
  240. # Don't allow downloaded files to be created as StringIO
  241. require 'open-uri'
  242. OpenURI::Buffer.send :remove_const, 'StringMax' if OpenURI::Buffer.const_defined?('StringMax')
  243. OpenURI::Buffer.const_set 'StringMax', 0
  244. require 'thread'
  245. length = docs.length
  246. mutex = Mutex.new
  247. i = 0
  248. (1..4).map do
  249. Thread.new do
  250. while doc = docs.shift
  251. status = begin
  252. download_doc(doc)
  253. 'OK'
  254. rescue => e
  255. "FAILED (#{e.class}: #{e.message})"
  256. end
  257. mutex.synchronize { puts "(#{i += 1}/#{length}) #{doc.name}#{ " #{doc.version}" if doc.version} #{status}" }
  258. end
  259. end
  260. end.map(&:join)
  261. end
  262. def download_doc(doc)
  263. target_path = File.join(Docs.store_path, doc.path)
  264. open "http://dl.devdocs.io/#{doc.path}.tar.gz" do |file|
  265. FileUtils.mkpath(target_path)
  266. file.close
  267. tar = UnixUtils.gunzip(file.path)
  268. dir = UnixUtils.untar(tar)
  269. FileUtils.rm_rf(target_path)
  270. FileUtils.mv(dir, target_path)
  271. FileUtils.rm(file.path)
  272. end
  273. end
  274. def package_doc(doc)
  275. path = File.join Docs.store_path, doc.path
  276. if File.exist?(path)
  277. tar = UnixUtils.tar(path)
  278. gzip = UnixUtils.gzip(tar)
  279. FileUtils.mv(gzip, "#{path}.tar.gz")
  280. FileUtils.rm(tar)
  281. else
  282. puts %(ERROR: can't find "#{doc.name}" documentation files.)
  283. end
  284. end
  285. def generate_manifest
  286. Docs.generate_manifest
  287. end
  288. end