docs.thor 10 KB

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